Linux网络编程杂谈(聊聊网络编程背后的故事)

数据是如何传输到物理网络上的?

以TCP为例,当 TCP 决定发送数据时,这些数据需要经过多个处理阶段才能真正被传输到物理网络。其中一个关键步骤是将数据移动到网络接口卡 (NIC)。以下是这个过程的详细描述:

  1. 数据序列化:

    • TCP 会为要发送的数据添加 TCP 头部,这创建了一个 TCP 数据段。
    • 这个 TCP 数据段再被 IP 层封装,添加 IP 头部,形成一个 IP 数据包。
    • 依据使用的物理媒体和链路层协议,如 Ethernet,此 IP 数据包再被封装,添加例如以太网的 MAC 头部和尾部。
  2. 队列化:

    • 准备好的数据包(例如以太网帧)被放置在一个队列中,等待 NIC 的传输。
    • 操作系统通常为每个 NIC 维护一个传输队列(也称为发送缓冲区或发送环)。
  3. NIC 的 DMA (Direct Memory Access):

    • 网络接口卡使用 DMA 来从系统内存中直接获取数据,而无需 CPU 的干预。
    • DMA 允许 NIC 在准备好时从发送队列中取出数据包,并将它们直接发送到网络。
  4. 物理传输:

    • 一旦 NIC 使用 DMA 获取了数据包,它就开始处理物理层的细节,如信号调制和编码。
    • 数据随后通过物理介质(例如电缆、光纤或无线电波)传输。
  5. 中断和确认:

    • 当 NIC 完成数据包的发送,它可能会产生一个中断通知 CPU 数据已被发送。
    • 这样,操作系统可以从其发送缓冲区中释放空间或进行其他必要的清理工作。
    • 对于 TCP 数据,一旦远端确认已接收到数据,TCP 将从其发送缓冲区中释放相关的数据。

这个过程的各种优化和细节可能会依据操作系统、NIC 设计和网络配置而有所不同,但上述流程为数据在大多数现代系统中的通常路径提供了一个概述。


listen()的工作原理是什么

listen() 是一个系统调用,用于使一个套接字处于监听状态,准备接受来自客户端的连接请求。它不会接受连接,只是将套接字设置为可以通过 accept() 接受连接。

参数:

  • sockfd: 是由 socket() 调用返回的文件描述符。
  • backlog: 定义了系统应该为此套接字维护的尚未由 accept() 接受的传入连接请求的最大数量。

基本工作流:

  1. 当客户端使用 connect() 调用请求连接到服务器时,连接请求被放入服务器的一个队列中。
  2. 此队列的大小由 backlog 参数指定。
  3. 当队列满时,系统开始拒绝额外的连接请求。
  4. 服务器进程可以调用 accept() 来从队列中取出一个连接请求并处理它。

函数实现:

为了说明如何实现这个函数,我们可以考虑以下简化过的伪代码。请注意,这只是一个大概的实现,并没有考虑所有的边界条件和错误处理,也没有实际的系统调用和内核交互。

// 简化的数据结构定义
typedef struct {
    Queue *connection_requests;  // 保存连接请求的队列
    int is_listening;            // 标志位,指示套接字是否正在监听
} Socket;

// listen函数的简化实现
int listen(int sockfd, int backlog) {
    // 获取与文件描述符关联的Socket对象
    Socket *sock = get_socket_object_from_fd(sockfd);

    // 检查套接字是否已经被绑定到一个地址(通过bind())
    if (sock == NULL || !is_socket_bound(sock)) {
        return -1;  // 返回错误
    }

    // 初始化连接请求队列
    sock->connection_requests = create_queue(backlog);

    // 设置监听标志
    sock->is_listening = 1;

    return 0;  // 成功返回
}

请注意,实际的 listen() 实现会涉及到更复杂的逻辑,并且大部分工作是在操作系统内核中完成的。上述伪代码只是为了提供一个高层次的概述。在现实的操作系统中,listen() 的实现涉及到许多底层的细节、错误检查、兼容性处理以及与其他系统调用的交互。


accept()的工作原理是什么

accept() 是一个系统调用,用于从监听套接字的连接请求队列中取出第一个连接请求,并创建一个新的套接字文件描述符,以便于与发起连接的客户端通信。

参数:

  • sockfd: 是由 socket() 调用返回的文件描述符,该套接字应已经通过 bind() 绑定到一个地址并通过 listen() 开始监听。
  • addr: 是一个指针,用于存储客户端的地址信息。
  • addrlen: 是一个输入输出参数。在调用 accept() 之前,它应该被设置为 addr 所指向的缓冲区的大小。当 accept() 返回时,addrlen 将被设置为实际地址的长度。

工作流:

  1. accept() 会检查与 sockfd 关联的连接请求队列。
  2. 如果队列不为空,accept() 会取出第一个连接请求,并为其创建一个新的套接字文件描述符。
  3. 如果队列为空并且 sockfd 是非阻塞的,accept() 会立即返回错误。
  4. 如果队列为空但 sockfd 是阻塞的,accept() 会挂起调用线程,直到有一个连接请求可用为止。

伪代码实现:

这是一个非常简化的 accept() 函数实现伪代码。请注意,实际的系统调用实现会在操作系统内核中进行,并涉及许多底层细节。

typedef struct {
    Queue *connection_requests;  // 保存连接请求的队列
    int is_listening;            // 标志位,指示套接字是否正在监听
} Socket;

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) {
    // 获取与文件描述符关联的Socket对象
    Socket *sock = get_socket_object_from_fd(sockfd);

    // 检查套接字是否处于监听状态
    if (!sock->is_listening) {
        return -1;  // 返回错误
    }

    // 如果没有连接请求,并且套接字是非阻塞的,返回错误
    if (queue_is_empty(sock->connection_requests) && is_socket_non_blocking(sockfd)) {
        return -1;  // 返回错误
    }

    // 如果没有连接请求,等待一个连接请求
    while (queue_is_empty(sock->connection_requests)) {
        wait_for_connection_request(sock);
    }

    // 从队列中取出一个连接请求
    ConnectionRequest *request = dequeue(sock->connection_requests);

    // 使用连接请求信息填充addr和addrlen
    if (addr != NULL && addrlen != NULL && *addrlen >= request->addr_length) {
        memcpy(addr, &request->client_addr, request->addr_length);
        *addrlen = request->addr_length;
    }

    // 为连接请求创建一个新的套接字文件描述符
    int new_sockfd = create_new_socket_for_request(request);

    return new_sockfd;  // 返回新的文件描述符
}

与先前的伪代码一样,这只是为了说明 accept() 的基本逻辑,实际的系统调用实现会涉及更复杂的逻辑、错误处理、资源管理、以及与其他系统调用和内核组件的交互。


如何理解:如果没有连接请求,并且套接字是非阻塞的,返回错误

在非阻塞模式下,系统调用(如accept())不会挂起调用线程直到请求完成。相反,它们会立即返回并可能报告一个“立即可用”的或“没有数据”的类型的错误。

对于accept()系统调用:

  • 当套接字设置为阻塞模式时:如果没有待处理的连接请求,accept()调用将阻塞,直到有连接请求到来为止。

  • 当套接字设置为非阻塞模式时:如果没有待处理的连接请求,accept()不会阻塞。它会立即返回,并通过返回值或设置某种错误状态来表示“没有可接受的连接”。

在非阻塞模式下,返回错误(通常是EAGAINEWOULDBLOCK)的原因是为了告诉调用者目前没有连接请求可接受,并允许调用者决定下一步的操作。这为设计高效的事件驱动或异步系统提供了便利,因为它们可以在没有活动发生时执行其他任务,而不是被系统调用挂起。

非阻塞的accept()是事件驱动编程模型(如select()poll()epoll()等)中的常见用法,这种模型可以在单一线程或进程中高效地处理大量并发连接。

如何将套接字设置为非阻塞模式

要将套接字设置为非阻塞模式,通常需要使用fcntl函数修改套接字的文件描述符标志。以下是如何为套接字设置非阻塞模式的示例:

#include 
#include 
#include 

int set_nonblocking(int sockfd) {
    int flags;

    // 获取当前文件描述符的标志
    flags = fcntl(sockfd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl");
        return -1;
    }

    // 添加O_NONBLOCK标志
    flags |= O_NONBLOCK;

    // 使用修改后的标志更新文件描述符
    if (fcntl(sockfd, F_SETFL, flags) == -1) {
        perror("fcntl");
        return -1;
    }

    return 0;
}

使用上述set_nonblocking函数,可以为任何套接字设置非阻塞模式,例如:

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
    perror("socket");
    exit(1);
}

if (set_nonblocking(sockfd) == -1) {
    // 处理错误
    exit(1);
}

一旦设置为非阻塞模式,涉及此套接字的系统调用(如accept(), read(), write()等)都不会阻塞,而是在无法立即完成请求时立即返回。


从客户端的角度来看呢?

当一个客户端尝试连接到监听套接字上时,内核将会为该连接请求创建一个与之相关的数据结构(例如,一个表示连接的数据结构)。然后,该连接请求会被加入到与监听套接字相关联的连接请求队列中。具体的机制和时机取决于底层的网络实现和操作系统。

在典型的TCP实现中,当一个SYN分段(表示开始一个新的连接的TCP分段)到达服务器时,以下步骤会发生:

  1. SYN分段的接收:当服务器接收到来自客户端的SYN分段时,它意味着客户端希望建立一个新的连接。

  2. 半连接队列:在某些实现中,刚刚到达的连接首先会被放置在一个所谓的"半连接队列"中。此时,连接尚未完全建立(它仍然处于三次握手的中间阶段)。

  3. SYN-ACK的发送:服务器会响应一个SYN-ACK分段,表示它已经接收到连接请求并且愿意建立连接。

  4. 完成队列:当客户端回应一个ACK分段,三次握手就完成了,此时连接从"半连接队列"移动到"完成队列"中。

  5. 从队列中取出连接:当服务器上的应用程序调用accept()函数时,它实际上是从这个"完成队列"中取出一个已完成的连接。如果队列为空(即没有等待的连接),accept()的行为取决于套接字是否为非阻塞:如果是阻塞模式,它会挂起等待直到有一个连接可用,而如果是非阻塞模式,它会立即返回一个错误。

对于上面accept()伪代码实现中的dequeue(sock->connection_requests),可以将其视为一个从"完成队列"中取出一个已完成连接的抽象表示。而连接请求是在三次握手完成时被加入到这个队列中的。当然,这只是一个简化的描述,实际的TCP和套接字实现可能会有更多的细节和考虑因素。

什么是半连接队列

当我们谈到TCP连接时,实际上涉及了很多资源。为每个连接分配的资源可能包括:

  • 套接字结构和与之关联的缓冲区。
  • 对于传入和传出数据的内存分配。
  • 与连接状态、计时器、重传等相关的控制结构。

在正常的三次握手过程中,当服务器收到一个SYN分段(第一步)时,它会响应一个SYN-ACK分段(第二步)并等待客户端的最后一个ACK分段(第三步)。正是在这个等待期间,半连接队列发挥了其关键作用。

为了理解如何节约资源,让我们深入探讨半连接队列的工作原理:

  1. 限制记录大小:当一个SYN请求到达服务器时,服务器不会立即为这个连接分配所有必要的资源。相反,它只是在半连接队列中为该连接存储一个简化的记录。这个记录通常仅包含必要的信息,例如源IP、源端口和其他一些用于标识这个连接请求的信息。这个记录的大小远小于一个完整的套接字结构,因此在内存使用上更为高效。

  2. 有界队列:半连接队列的大小是有限的。当它满了以后,新到达的SYN请求可能会被丢弃。这自然地为系统提供了一个保护机制,使其不会因为大量的SYN请求而耗尽资源。

  3. 超时机制:为了防止由于恶意SYN请求或网络问题导致的记录堆积,半连接队列中的每个记录都有一个超时值。如果在超时时间内没有收到客户端的ACK响应,该记录将被从队列中删除。这确保了即使在SYN洪水攻击的情况下,旧的、未完成的连接请求也会被清理出队列。

  4. 动态调整:在一些现代操作系统中,根据当前的网络条件和系统负载,半连接队列的大小和行为可以动态调整。

通过这些方式,半连接队列为系统提供了一个防火墙,保护系统免受大量SYN请求的侵害,并确保只有真正想要建立连接的客户端可以进入系统。这不仅限制了资源使用,还为有效连接提供了更好的服务质量。

半连接队列的优点

半连接队列(也被称为"SYN队列")的优点:

  1. 处理连接洪水攻击:在所谓的SYN洪水攻击中,攻击者快速地发送大量的SYN分段(连接请求)到目标服务器,但从不完成三次握手。这导致服务器为每一个到达的SYN请求分配资源,等待来自客户端的响应,从而可能耗尽系统资源。半连接队列限制了这种资源分配,因为在三次握手完成之前,连接不会被完全建立。

  2. 提高效率:当服务器接收到SYN分段时,它并不立即为该连接分配所有必要的资源(例如,完整的套接字数据结构或相关的内存缓冲区)。相反,它只是在半连接队列中存储一个简化的连接记录。只有当连接确实建立(即三次握手完成)时,才会为其分配完整的资源。

  3. 异步处理:在高并发的网络环境中,服务器可能会同时收到大量的SYN请求。半连接队列允许服务器以异步的方式处理这些请求,先对它们进行排队,然后再逐一处理。

  4. 避免不必要的资源分配:并不是所有的SYN请求都会完成三次握手。有些可能是由于网络中断、客户端崩溃或其他原因而永远不会完成。通过使用半连接队列,服务器可以避免为这些不会完成的连接分配不必要的资源。

在实际的实现中,半连接队列的大小是有限的。当队列满时,新到达的连接请求可能会被丢弃,直到有足够的空间为止。这也是为什么在高并发场景下,服务器可能需要对半连接队列的大小进行调整,以应对大量的并发连接请求。

你可能感兴趣的:(工程化C,Linux,网络,linux)