本文涉及到socket模型/多进程模型/多线程模型/IO多路复用模型,下面进行展开。

基础的socket模型

让客户端和服务端能在网络中进行通信得使用socket编程,它可以跨主机间通信,这是进程间通信里比较特殊的的地方。
双方在进行网络通信前得各自创建一个socket,在创建的过程中可以指定网络层使用的是IPv4还是IPv6传输层使用的是TCP还是UDP。我们具体来看下服务端的socket编程过程是怎样的。
服务端首先调用socket()函数,创建网络协议为IPv4,以及传输协议为TCP的socket,接着调用bind函数,给这个sokcet绑定一个IP地址和端口,绑定IP地址和端口的目的:

  1. 绑定端口的目的:当内核收到TCP报文,通过TCP头里的端口号找到我们的应用程序,然后将数据传递给我们。
  2. 绑定IP的目的:一台机器是可以有多个网卡的,每个网卡都有对应的IP地址,当绑定一个网卡时,内核在收到该网卡上的包才会发给对应的应用程序。

绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听,此时对应 TCP 状态图中的 listen,如果我们要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听。服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。

下面我们再来看下客户端发起连接的过程,客户端在创建好socket后,调用connect()函数发起连接,函数参数需要指明服务端的IP与端口,然后就是TCP的三次握手。
在TCP连接的过程中,服务器的内核实际上为每个socket维护了两个队列:

  • 一个是还没有完全建立连接的队列,称为TCP半连接队列,这个队列都是没有完成三次扬的连接,此时服务端处于syn_rcvd状态。
  • 一个是已经建立连接的队列,称为TCP全连接队列,这个队列都是完成了三次扬的连接,此时服务端处于established状态。

当TCP全连接队列不为空后,服务端的accept()函数就会从内核中的TCP全连接队列里取出一个已经完成连接的socket返回应用程序,后续数据传输都使用这个socket。需要注意的是,监听的socket与真正用来传数据的sokcet是两个:一个叫作监听socket,一个叫作已连接soket。建立连接后,客户端与服务端就开始相互传输数据了,双方都可以通过read()和write()函数来读写数据。这就是TCP协议的socket程序的调用过程。

单进程模型

上面提到的TCP socket调用流程是最简单、最基本的,它基本上只能一对一通信,因为使用的是同步阻塞的方式,当服务端还没处理完一个客户端的网络IO时,或读写操作发生阻塞时,其它客户端是无法与服务端连接的。那在这种单进程模式下服务器单机理论最大能连接多少客户端呢?TCP连接是由四元组唯一确认的,这里的四元组指:本机IP,本机端口,对端IP,对端端口。服务器作为服务方,通常会在本地固定监听一个端口,因此服务里的本机IP与本机端口是固定的,所以最大TCP连接数=客户端*客户端端口数。对于IPv4,客户端的IP数最多为2的32次方,客户端的端数最多为2的16次方,也就是服务端单机最大TCP连接数约为2的48次方,不过这是我们没有考虑其它限制因素的,主要的限制有:

  • 文件描述符,socket实际上是一个文件,也就会对应一个文件描述符,在linux下,单个进程打开的文件描述符是有限制的,默认为1024,不过我们可以进行修改
  • 系统内存,每个TCP连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的

显然这种单进程模式支持的连接数是很有限的。

多进程模型

基于最原始的阻塞网络IO,如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理。服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept函数就会返回一个‘已连接socket’,这时通过fork函数创建一个子进程,实际上就是将父进程所有相关的资源都复制一份,像文件描述符、内存地址空间、程序计数器、执行的代码等。我们可以通过返回值来区分父子进程,父子进程各有分工:父进程只需要关心‘监听socket’而不用关心‘已连接socket’,子进程则只关心‘已连接socket’而不用关心‘监听socket’,可以看下图:
image.png
子进程占用着系统资源,随着子进程数量上的增加,还有进程间上下文切换(上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源),服务端肯定是应对不了。

多线程模型

既然进程间上下文切换很耗性能,于是轻量级的线程就出现了。线程是运行在进程中的一个‘逻辑流’,一个进程中可以运行多个线程,同进程里的多个线程可以共享进程的部分资源,像文件描述符列表、进程空间、代码、全局数据、堆、共享库,因为这些资源是共享的所以在使用的时候不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,所以这比进程间的上下文切换开销要小很多。
image.png
因为将已连接的socket加入到的队列是全局的,每个线程都会操作,为了避免多线程间的竞争,线程在操作这个队列前需要加锁。理念上多线程模型比多进程模型支持的连接要多,但线程间的调度、共享资源的竞争也很耗性能,另外线程也不是无限的,一个线程会占1M的内存空间,如果需要处理的连接特别耗时那性能就会直线下降。

IO多路复用

既然为每个请求分配一个进程/线程的方式不合适,那有没可能只使用一个进程来维护多个socket呢?当然是有的,就是IO多路复用技术。一个进程虽然任一时刻只能处理一个请求,但是处理每个请求将耗时控制在极短的时间内,这样1秒内就可以处理上千个请求,将时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个CPU并发多个进程,所以也叫时分多路复用。
我们熟悉的select/poll/epoll内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获得多个事件。select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll
这两种实现的方式差不多所以放在一起说。select实现多路复用的方式是,将已连接的socket都放到一个文件描述符集合,然后调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此socket标记为可读或可写,接着再将整个文件描述符集合拷贝回用户态里,然后用户态还需要再次遍历找到可读或可写的socket然后再对其处理。所以,对于socket这种方式,需要进行2次遍历文件描述符集合,一次是在内核态一次是在用户态,而且还会发生2次拷贝文件描述符集合,先从用户空间传入内核空间,由内核空间修改后,再传出到用户空间中。
select使用固定长度的bitsMap表示文件描述符集合,文件描述符的个数是有限制的,在linux系统中,由内核中的FD_SETSIZE限制,默认最大值为1024,只能监听0~1023的文件描述符。
poll不再使用BitsMap来存储所关注的文件描述符,而是使用动态数组,以链表形式来组织,突破了select的文件描述符个数限制,不过还是会受到系统文件描述符限制。
poll与select并没有太大的本质区别,都是使用线性结构存储进程关注的socket集合,因此都需要遍历文件描述符集合来找到可读或可写的socket,时间复杂度为O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级别增长。
epoll
epoll通过两个方面来处理select/poll的问题。

  1. epoll在内核里使用红黑树来跟踪进程所有待检测的文件描述符,将需要监控的socket通过epoll_ctl()函数加入内核中的红黑树里,红黑树的操作时间复杂度一般是O(logn),通过对红黑树的操作,就不需要像select/poll每次操作时都传入整个socket集合,只需要传入一个待检测的socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  2. epoll使用事件驱动的机制,内核维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用epoll_wait()函数时(还是需要用户主动去调而不是内核去通知用户,说明是同步),只会返回有事件发生的文件描述符的个数,不需要像select/poll那样轮询扫描整个socket集合,从而大大提高了检测是的效率。

image.png

epoll的方式即使监听的socket数量增多时,效率也不会大幅度降低,能够同时监听的socket数量上限为系统定义的进程打开的最大文件描述符个数。Redis 在设计和实现网络通信框架时,就基于epoll机制进行了封装开发,实现了用于网络通信的事件驱动框架,从而使得 Redis 虽然是单线程运行,但是仍然能高效应对高并发的客户端访问。

epoll支持的两种事件触发模式,分别是边缘触发与水平触发。

  • 边缘触发:当被监听的socket描述符上有可读事件发生时,服务器端只会从epoll_wait中苏醒一次,即使进程没有调用read函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完。
  • 水平触发:录被监听的socket上有可读事件发生时,服务器端不断地从epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束,目的是告诉我们有事件需要读取。

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。

注意,Java NIO在Linux下默认也是epoll机制,但是JDK中epoll的实现却是有漏洞的,其中最有名的java nio epoll bug就是即使是关注的select轮询事件返回数量为0,NIO照样不断的从select本应该阻塞中wake up出来,导致CPU 100%问题。参考Java nio 空轮询bug到底是什么。针对这个问题,我们尽量使用更高版本的JDK(高版本也存在,但概率要低),或使用netty代替。

总结

最基础的TCP的socket编程,它是阻塞IO模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络IO模型。
比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程。当客户端不断增大时,进程/线程的高度还有上下文切换及它们占用的内存都会有成瓶颈。
为了解决上面这个问题,就出现了IO的多路复用,可以只在一个进程里处理多个文件的IO,linux下有三种提供IO多路复用的API,分别是是:select/poll/epoll。
select与poll没有本质的区别,它们内部都是使用‘线性结构’来存储进程关注的socket集合。在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。
很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销。
于是出现了epoll,它是通过在内核使用红黑树来存储所有待检测的文件描述符,因为不需要每次都传入整个socket集合,就减少了内核和用户空间大量的数据拷贝和内存分配,另一点就是使用事件驱动的机制在内核维护了一个链表来记录就绪事件而不需要将select/poll轮询整个集合,大大提高了检测的效率。
另外,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。

本文主要参考:
小林coding解答IO多路复用的文章


步履不停
38 声望13 粉丝

好走的都是下坡路