以TCP为例,当 TCP 决定发送数据时,这些数据需要经过多个处理阶段才能真正被传输到物理网络。其中一个关键步骤是将数据移动到网络接口卡 (NIC)。以下是这个过程的详细描述:
数据序列化:
队列化:
NIC 的 DMA (Direct Memory Access):
物理传输:
中断和确认:
这个过程的各种优化和细节可能会依据操作系统、NIC 设计和网络配置而有所不同,但上述流程为数据在大多数现代系统中的通常路径提供了一个概述。
listen()
是一个系统调用,用于使一个套接字处于监听状态,准备接受来自客户端的连接请求。它不会接受连接,只是将套接字设置为可以通过 accept()
接受连接。
参数:
sockfd
: 是由 socket()
调用返回的文件描述符。backlog
: 定义了系统应该为此套接字维护的尚未由 accept()
接受的传入连接请求的最大数量。基本工作流:
connect()
调用请求连接到服务器时,连接请求被放入服务器的一个队列中。backlog
参数指定。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()
是一个系统调用,用于从监听套接字的连接请求队列中取出第一个连接请求,并创建一个新的套接字文件描述符,以便于与发起连接的客户端通信。
参数:
sockfd
: 是由 socket()
调用返回的文件描述符,该套接字应已经通过 bind()
绑定到一个地址并通过 listen()
开始监听。addr
: 是一个指针,用于存储客户端的地址信息。addrlen
: 是一个输入输出参数。在调用 accept()
之前,它应该被设置为 addr
所指向的缓冲区的大小。当 accept()
返回时,addrlen
将被设置为实际地址的长度。工作流:
accept()
会检查与 sockfd
关联的连接请求队列。accept()
会取出第一个连接请求,并为其创建一个新的套接字文件描述符。sockfd
是非阻塞的,accept()
会立即返回错误。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()
不会阻塞。它会立即返回,并通过返回值或设置某种错误状态来表示“没有可接受的连接”。
在非阻塞模式下,返回错误(通常是EAGAIN
或EWOULDBLOCK
)的原因是为了告诉调用者目前没有连接请求可接受,并允许调用者决定下一步的操作。这为设计高效的事件驱动或异步系统提供了便利,因为它们可以在没有活动发生时执行其他任务,而不是被系统调用挂起。
非阻塞的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分段)到达服务器时,以下步骤会发生:
SYN分段的接收:当服务器接收到来自客户端的SYN分段时,它意味着客户端希望建立一个新的连接。
半连接队列:在某些实现中,刚刚到达的连接首先会被放置在一个所谓的"半连接队列"中。此时,连接尚未完全建立(它仍然处于三次握手的中间阶段)。
SYN-ACK的发送:服务器会响应一个SYN-ACK分段,表示它已经接收到连接请求并且愿意建立连接。
完成队列:当客户端回应一个ACK分段,三次握手就完成了,此时连接从"半连接队列"移动到"完成队列"中。
从队列中取出连接:当服务器上的应用程序调用accept()
函数时,它实际上是从这个"完成队列"中取出一个已完成的连接。如果队列为空(即没有等待的连接),accept()
的行为取决于套接字是否为非阻塞:如果是阻塞模式,它会挂起等待直到有一个连接可用,而如果是非阻塞模式,它会立即返回一个错误。
对于上面accept()伪代码实现中的dequeue(sock->connection_requests)
,可以将其视为一个从"完成队列"中取出一个已完成连接的抽象表示。而连接请求是在三次握手完成时被加入到这个队列中的。当然,这只是一个简化的描述,实际的TCP和套接字实现可能会有更多的细节和考虑因素。
当我们谈到TCP连接时,实际上涉及了很多资源。为每个连接分配的资源可能包括:
在正常的三次握手过程中,当服务器收到一个SYN分段(第一步)时,它会响应一个SYN-ACK分段(第二步)并等待客户端的最后一个ACK分段(第三步)。正是在这个等待期间,半连接队列发挥了其关键作用。
为了理解如何节约资源,让我们深入探讨半连接队列的工作原理:
限制记录大小:当一个SYN请求到达服务器时,服务器不会立即为这个连接分配所有必要的资源。相反,它只是在半连接队列中为该连接存储一个简化的记录。这个记录通常仅包含必要的信息,例如源IP、源端口和其他一些用于标识这个连接请求的信息。这个记录的大小远小于一个完整的套接字结构,因此在内存使用上更为高效。
有界队列:半连接队列的大小是有限的。当它满了以后,新到达的SYN请求可能会被丢弃。这自然地为系统提供了一个保护机制,使其不会因为大量的SYN请求而耗尽资源。
超时机制:为了防止由于恶意SYN请求或网络问题导致的记录堆积,半连接队列中的每个记录都有一个超时值。如果在超时时间内没有收到客户端的ACK响应,该记录将被从队列中删除。这确保了即使在SYN洪水攻击的情况下,旧的、未完成的连接请求也会被清理出队列。
动态调整:在一些现代操作系统中,根据当前的网络条件和系统负载,半连接队列的大小和行为可以动态调整。
通过这些方式,半连接队列为系统提供了一个防火墙,保护系统免受大量SYN请求的侵害,并确保只有真正想要建立连接的客户端可以进入系统。这不仅限制了资源使用,还为有效连接提供了更好的服务质量。
半连接队列(也被称为"SYN队列")的优点:
处理连接洪水攻击:在所谓的SYN洪水攻击中,攻击者快速地发送大量的SYN分段(连接请求)到目标服务器,但从不完成三次握手。这导致服务器为每一个到达的SYN请求分配资源,等待来自客户端的响应,从而可能耗尽系统资源。半连接队列限制了这种资源分配,因为在三次握手完成之前,连接不会被完全建立。
提高效率:当服务器接收到SYN分段时,它并不立即为该连接分配所有必要的资源(例如,完整的套接字数据结构或相关的内存缓冲区)。相反,它只是在半连接队列中存储一个简化的连接记录。只有当连接确实建立(即三次握手完成)时,才会为其分配完整的资源。
异步处理:在高并发的网络环境中,服务器可能会同时收到大量的SYN请求。半连接队列允许服务器以异步的方式处理这些请求,先对它们进行排队,然后再逐一处理。
避免不必要的资源分配:并不是所有的SYN请求都会完成三次握手。有些可能是由于网络中断、客户端崩溃或其他原因而永远不会完成。通过使用半连接队列,服务器可以避免为这些不会完成的连接分配不必要的资源。
在实际的实现中,半连接队列的大小是有限的。当队列满时,新到达的连接请求可能会被丢弃,直到有足够的空间为止。这也是为什么在高并发场景下,服务器可能需要对半连接队列的大小进行调整,以应对大量的并发连接请求。