Socket 模型
Socket 编程是一种使用 Socket 模型进行网络通信的编程技术。它是一种基于网络套接字的编程模型,用于实现不同计算机之间的数据传输。
事实上,在进行网络通信前,通信双方都要创建一个 Socket,双方的数据读写都要依赖于此。创建 Socket 时,可以指定网络层使用 IPV4 或者 IPV6,传输层使用 TCP 或者 UDP。
基于 TCP 的 Socket 编程
服务端的程序要先跑起来,监听等待客户端的连接和数据。服务端首先调用 socket() 函数创建一个网络协议为 IPV4、传输协议为 TCP 的 Socket,然后调用 bind() 函数绑定一个 IP 地址和端口号。
- 绑定 IP 地址:一台机器可以有多个网卡,每个网卡都有对应的 IP 地址,当绑定其中一个网卡时,操作系统内核收到该网卡上的包才会发给我们;
- 绑定端口号:当操作系统内核收到 TCP 报文时,需要通过 TCP 头部里的端口号来找到对应的应用程序,然后才能最终把数据传输给我们
服务端在绑定完 IP 地址和端口号之后,接着调用 listen() 函数进行监听(此时对应 TCP 状态图中的 listen),然后再调用 accept() 函数从内核中获取客户端的连接,如果此时没有客户端的连接,则会进入阻塞状态,直到客户端连接的到来。
同理,客户端在创建好 Socket 之后,会调用 connect() 函数发起连接,在该函数的参数中指明了服务端的 IP 地址和端口号,然后就是进行 TCP 三次握手。
在 TCP 连接过程中,服务器的内核实际上会为每个 Socket 都维护两个队列:
- TCP 半连接队列:还未完全建立连接的队列,这个队列里的都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 状态;
- TCP 全连接队列:已经建立连接的队列,这个队列里的都是完成了三次握手的连接,此时服务端处于 established 状态
当 TCP 全连接队列不为空时,服务端调用 accept() 函数,从内核中的 TCP 全连接队列里拿出一个已经完成三次握手的连接返回给应用程序,后续的数据传输都是通过这个 Socket。
需要注意的是,此时服务端的监听 Socket 和客户端的已连接 Socket 是两个 Socket。连接建立以后,客户端和服务端就可以通过 read() 和 write() 函数相互传输读写数据了。至此, 基于 TCP 协议的 Socket 程序的调用过程就结束了。
C10K 问题
C10K 问题是指支持同时处理 10000 个并发连接的服务器的挑战。C 代表 Concurrent(并发),10K 代表 10000,因此 C10K 表示服务器需要同时处理大量的并发连接。
在传统的服务器设计中,由于每个连接都需要一个操作系统级别的进程或线程来处理,而进程和线程的创建和销毁的开销较大,因此服务器往往无法同时处理大量的并发连接。这导致了 C10K 问题的出现。
为了解决 C10K 问题,有几种常见的策略和技术:
- 多进程/多线程模型:通过创建多个进程或线程来处理并发连接,但由于创建和销毁的开销较大,不适用于处理大规模并发连接;
- 事件驱动模型:使用事件循环和非阻塞 I/O 来处理并发连接,只使用少量的线程或进程,并通过事件通知机制处理请求和响应;
- 异步 I/O 模型:通过使用异步 I/O 操作和回调函数来处理并发连接,可以高效地处理大量的并发请求;
- 消息队列模型:将请求和响应分发到消息队列中,由多个处理节点并行处理请求和响应,提高并发性能
我们知道 TCP 连接是由四个值组成的四元组唯一确认的,这四个值分别是「源 IP 地址」、「源端口号」、「目标 IP 地址」、「目标端口号」。
作为服务方,服务端通常会在本地固定监听一个端口,等待客户端的连接。因此服务端的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组而言,只有目标 IP 地址和目标端口号是会变化的,所以服务端的最大 TCP 连接数 = 客户端 IP 数 × 客户端端口数。
以 IPv4 举例,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,所以服务端单机的最大 TCP 连接数约为 2 的 48 次方。
但实际上服务端肯定承载不了那么大的连接数,其主要受两个方面的限制:
- 文件描述符:基于 Linux 一切皆文件的理念,在内核中 Socket 实际上也是一个文件,也对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,默认是 1024,可以通过 ulimit 增大文件描述符的数目;
- 系统内存:每个 TCP 连接在内核中都有对应的数据结构,这意味着每个连接都是会占用一定的内存
多进程模型
如上所述的最基本的 Socket 通信,只能用于一对一的场景,因为其使用的是同步阻塞的方式,当服务端还没处理完某个客户端的网络 I/O 时,或者读写操作发生阻塞时,其他客户端是无法与服务端连接的。
如果服务端要支持多个客户端,可以使用多进程模型,即为每一个客户端都分配一个进程来处理请求。
服务端的主进程负责监听客户端的连接,一旦与客户端连接完成,服务端调用 accept() 函数返回一个已连接的 Socket,此时通过 fork() 函数创建一个子进程,实际上就是把父进程的所有相关的东西都复制出一份(包括文件描述符、内存地址空间、程序计数器、执行的代码等)。
子进程刚复制完的时候,几乎与父进程是一模一样的。不过,可以根据返回值来区分到底是父进程还是子进程,如果返回值是 0,则表示是子进程;如果返回值是其他的整数,就是父进程。
而正因为子进程会复制父进程的文件描述符,所以就可以直接使用已连接的 Socket 和客户端进行通信。
综上所述,子进程不需要关心「监听 Socket」,只需要关心「已连接的 Socket」;而父进程则恰恰相反,它将对客户端的服务交给子进程来处理,因此父进程不需要关心「已连接的 Socket」,只需要关心「监听 Socket」。
需要注意的是,当子进程退出时,内核里还会保留该进程的一些信息,这部分也是会占用内存的,如果没做好回收工作,那么这部分就会变成僵尸进程,而随着僵尸进程的增多,会慢慢耗尽系统资源。
因此,父进程要在子进程退出后妥善地回收子进程的资源:
- wait() 函数:暂停父进程的执行,直到任何一个子进程退出,并返回子进程的退出状态。通过调用 wait()函数,父进程可以获取子进程的退出状态,并完成资源的回收;
- waitpid() 函数:提供了更灵活的选项来等待指定子进程的退出,并回收其资源。通过传递特定的子进程 ID 或使用其他选项,父进程可以指定要等待的子进程
多进程模型,在应对 100 个客户端时还是可行的,但是当客户端的数量高达一万时肯定扛不住的,因为每产生一个进程,势必会占据一定的系统资源,而且进程间的上下文切换的开销是很大的,性能会大打折扣。
多线程模型
既然进程的创建和销毁的开销很大,那么是否可以考虑使用多线程模型呢?
我们知道,一个进程里可以包含多个线程,同一个进程内的线程共享该进程的部分资源,如文件描述符列表、进程空间、代码、全局数据、堆等,这些共享资源在上下文切换时不需要进行切换,线程只需要切换自己的非共享资源即可,如私有数据、寄存器等,因此同一个进程内的线程的上下文切换的开销要比进程小得多。这也是线程比进程更轻量级的一个原因。
当服务端与客户端完成 TCP 连接后,通过 pthread_create() 函数创建线程,然后将已连接的 Socket 的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
但是,每来一个连接,就需要创建一个线程,而且线程运行完后,操作系统还得销毁线程。虽说线程上下文切换的开销并不大,但如果是频繁地创建和销毁,那么系统开销也是不小的。
因此,我们可以使用线程池的方式来避免线程的频繁创建和销毁。所谓线程池,就是提前创建好若干个线程,这样每当有新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后由线程池里的线程负责从队列中取出该 Socket 并进行处理。
注意,这个队列是全局的,每个线程都会操作,所以为了避免多线程竞争,线程在操作这个队列前要加锁。
多线程模型其实还是有问题的,因为每新来一个 TCP 连接,就需要为其分配一个进程或者线程,那么如果要达到 C10K,就意味着一台机器要维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统还是扛不住的。
I/O 多路复用
一个进程虽然一次只能处理一个请求,但我们可以将每次处理请求的时间缩短为 1ms,这样在 1s 内就可以支持处理大约 1k 个请求,从宏观角度看就相当于多个请求复用了一个进程,即多路复用,这种思想类似于 CPU 并发支持多个进程,所以也称为时分多路复用。
常见的 I/O 多路复用系统调用函数有 select、poll 和 epoll。这些函数会阻塞等待,直到有一个或多个文件描述符就绪(可读或可写)或超时。一旦有文件描述符就绪,进程就可以立即对其进行读写操作,而不必等待其他文件描述符。
简单来说,就是在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接所对应的请求。
- select
select 是最古老的 I/O 多路复用函数,适用于大多数 UNIX 系统。它通过在一个或多个文件描述符上进行轮询来检查可读、可写和异常事件的状态。select 函数会阻塞等待,直到有文件描述符就绪或超时。
具体实现多路复用的方式是:将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,内核检查的方式很粗暴,即遍历文件描述符集合,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历找到可读或可写的 Socket,最后再对其处理。
所以,对于 select,需要进行 2 次「遍历」文件描述符集合(一次是在内核态里,一次是在用户态里),而且还会发生 2 次「拷贝」文件描述符集合(先从用户态传入内核态,由内核修改后,再传回用户态中)。
此外,select 有一些限制,比如监视的文件描述符数量有限。在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认为 1024,所以只能监听 0~1023 的文件描述符。
- poll
poll 在 select 的基础上进行了改进,适用于大多数 UNIX 系统。与 select 相比,poll 使用了一个 pollfd 结构数组来描述要监视的文件描述符及其事件,减少了每次调用的参数复制开销。
poll 函数也会阻塞等待,直到有文件描述符就绪或超时。但是与 select 相比,poll 在处理大量文件描述符时性能更好。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此它们都需要遍历文件描述符集合来找到可读或可写的 Socket,其时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,随着并发数的增加,性能的损耗会呈指数级增长。
- epoll
epoll 是 Linux 特有的 I/O 多路复用机制,它提供了更高效的事件驱动模型。
epoll 使用了一个事件表来存储要监视的文件描述符和事件,通过注册事件来提供更高的性能。
epoll 提供了三个接口:epoll_create 用于创建一个事件表,epoll_ctl 用于注册和取消注册事件,epoll_wait 用于等待事件发生。epoll_wait 函数是非阻塞的,它只返回已发生的事件,而不需要遍历整个事件表。这使得 epoll 非常适合处理大量并发连接。
epoll 通过两个方面,很好地解决了 select/poll 的问题:
- epoll 在内核里使用了红黑树数据结构来存储和跟踪要监视的文件描述符(Socket),把需要监控的 Socket 通过 epoll_ctl() 函数加入到内核中的红黑树里。红黑树是一种高效的平衡二叉查找树,在增删改的平均时间复杂度上具有较好的性能(O(log n))。相比之下,select和poll每次操作时需要将整个Socket集合传入内核,而不具备像epoll红黑树那样高效地管理已注册的Socket;
- epoll 采用事件驱动机制。在内核中,它维护了一个就绪事件列表(ready-list),当某个 Socket 有事件发生时,内核会将该 Socket 加入到就绪事件列表中。而用户调用 epoll_wait 函数时,只会返回处于就绪状态的文件描述符,而不需要像 select 和 poll 那样轮询扫描整个 Socket 集合。这种事件驱动机制极大地提高了检测的效率,避免了无意义的遍历
总的来说,epoll 通过利用红黑树数据结构和事件驱动机制,提供了高效的 I/O 多路复用。它减少了内核态与用户态之间的大量数据拷贝和内存分配,以及无效的遍历操作,大大提高了 I/O 事件检测的效率。
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT):
- 边缘触发方式只在 I/O 状态变化时通知应用程序,即当某个文件描述符发生状态变化时(例如可读或可写),epoll_wait 函数仅返回一次。如果应用程序未在之前处理完该文件描述符的数据,那么下次再调用 epoll_wait 时,不会再次触发通知,除非文件描述符状态再次发生变化;
- 边缘触发通知应用程序只有在状态发生变化的瞬间才触发,因此需要及时处理文件描述符上的数据,以免错过变化;
- 水平触发方式会持续通知应用程序,只要文件描述符处于就绪状态,epoll_wait 函数就会返回该文件描述符。如果应用程序未在之前处理完该文件描述符的数据,再次调用 epoll_wait 时,仍然会返回该文件描述符;
- 水平触发通知应用程序只是告诉文件描述符当前处于就绪状态,并不会跳过处理,因此应用程序需要主动处理文件描述符的数据
这两种方式类似于,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发送短信通知你,直到你取出了快递,这个就是水平触发。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,而系统调用也是有一定的开销的,毕竟也存在上下文的切换。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
综上所述,边缘触发和水平触发是 epoll 在通知应用程序 I/O 事件时的两种方式。边缘触发只在 I/O 状态变化瞬间触发通知,需要及时处理数据;而水平触发则持续通知处于就绪状态的文件描述符,需要应用程序主动处理数据。在使用 epoll 时,开发人员需要根据具体场景选择适合的触发方式来处理 I/O 事件。
参考资料
- https://xiaolincoding.com/os/8_network_system/selete_poll_epoll.html#_9-2-i-o-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8-select-poll-epoll