本文转载自微信公众号“小林编码”,作者小林编码。转载本文请联系小林编码公众号。这一次,我们用最简单的socket网络模型一步步过渡到I/O多路复用。但我不会详细讲每个系统调用的参数。书上肯定比我说的更详细。好吧,我们走吧!最基本的Socket模型是客户端和服务器端可以在网络中通信的情况下使用Socket编程。它是进程间通信的一种特殊方式。沟通。Socket的中文名称是Socket,乍一看比较容易混淆。实际上,在双方要进行网络通信之前,都必须各自创建一个Socket,相当于在客户端和服务器之间开了一个“口子”。双方在读取和发送数据时,都是通过这个“开口”。这样看,是不是感觉就像拿一根网线,一端插客户端,另一端插服务器,然后进行通信。创建Socket时,可以指定网络层使用IPv4还是IPv6,传输层使用TCP还是UDP。UDPSocket编程比较简单,这里只介绍基于TCP的Socket编程。服务器程序需要先运行,然后等待客户端的连接和数据。我们先来看看服务端的Socket编程流程。服务器首先调用socket()函数创建一个网络协议为IPv4、传输协议为TCP的Socket,然后调用bind()函数为该Socket绑定一个IP地址和端口。绑定这两个的目的是什么??端口绑定的目的:当内核收到TCP报文后,通过TCP头中的端口号找到我们的应用程序,然后将数据传递给我们。绑定IP地址的目的:一台机器可以有多个网卡,每个网卡都有一个对应的IP地址。当绑定了网卡后,内核会在收到网卡上的数据包后发送给我们。;绑定IP地址和端口后,可以调用listen()函数进行监听。这时对应TCP状态图中的listen。如果我们要判断服务器中的某个网络程序是否已经启动,我们可以使用netstate命令查看对应的端口号是否被监听。服务器进入监听状态后,调用accept()函数从内核获取客户端连接。如果没有客户端连接,它将阻塞并等待客户端连接到达。客户端如何发起连接?客户端创建Socket后,调用connect()函数发起连接。该函数的参数必须指明服务器的IP地址和端口号,然后开始期待已久的TCP三次握手。.在TCP连接过程中,服务器的内核实际上为每个Socket维护了两个队列:一个是还没有完全建立连接的队列,称为TCP半连接队列,这个队列是三个还没有完成的连接方式握手。此时服务器处于syn_rcvd状态;一种是建立连接的队列,称为TCP全连接队列。这个队列就是已经完成三次握手的连接。此时服务器处于已建立状态;为空后,服务器的accept()函数会从内核中的TCP全连接队列中取出一个连接完成的Socket返回给应用程序,并使用这个Socket进行后续的数据传输。注意监听Socket和真正用来传输数据的Socket有两种:一种叫做监听Socket;read()和write()函数用于读取和写入数据。至此,TCP协议的Socket程序的调用过程就结束了。整个过程如下图所示:看到这里,不知道大家有没有觉得读写Socket的方式就像读写文件一样。是的,基于Linux中万物皆文件的思想,socket在内核中也以“文件”的形式存在,并有相应的文件描述符。PS:下面会讲到内核中的数据结构。如果不感兴趣,这部分可以跳过,不影响后面的内容。文件描述符的作用是什么?每个进程都有一个数据结构task_struct,它有一个指向“文件描述符数组”的成员指针。该数组列出了该进程打开的所有文件的文件描述符。数组的下标是一个文件描述符,是一个整数,数组的内容是一个指针,指向内核中所有打开文件的列表,也就是说内核可以通过文件描述符找到对应的打开文件.然后每个文件都有一个inode。Socket文件的inode指向内核中的Socket结构。这个结构体中有两个队列,分别是发送队列和接收队列。这两个队列存储structssk_buff以链表的形式串在一起。sk_buff可以代表每一层的数据包。数据包在应用层称为data,在TCP层称为segment,在IP层称为packet,在数据链路层称为frame。你可能会好奇,为什么所有的数据包都只用一种结构来描述呢?协议栈采用分层结构。每一层都使用一个结构体,所以在层与层之间传输数据时,会出现多次拷贝,会大大降低CPU效率。因此,为了在层与层之间传输数据而不进行复制,只使用一个sk_buff结构来描述所有的网络数据包。它是怎么做到的?就是通过调整sk_buff中的数据指针,例如:当接收到发送报文时,从网卡驱动开始,通过协议栈逐层向上传输数据报,通过增加skb->data的值。发送消息时,创建一个sk_buff结构体,在数据缓冲区的头部预留足够的空间来填充每一层的头部,在经过各个下层协议时通过减小skb->data的值来增加协议头.从下图中可以看到发送消息时数据指针的移动过程。如何服务更多用户?上面说的TCPSocket调用过程是最简单最基础的。它基本上只能一对一通信,因为它采用了同步阻塞的方式。当服务器还没有处理完一个客户端的网络I/O时,或者读写操作被阻塞时,其他客户端无法连接到服务器。但是如果我们的服务器只能为一个客户端服务,那是一种资源浪费,所以我们需要改进这个网络I/O模型来支持更多的客户端。在改进网络I/O模型之前,我先问一个问题。你知道单机理论一台服务器可以连接多少个客户端吗?相信大家知道一个TCP连接是由一个四元组唯一确认的。这个四元组是:MachineIP,LocalPort,PeerIP,PeerPort。服务器作为服务端,通常会在本地监听一个端口,等待客户端的连接。所以服务器本地IP和端口是固定的,所以对于服务器TCP连接的四元数,只有对端IP和端口会改变,所以最大TCP连接数=客户端IP数×客户端数端口。对于IPv4,最大客户端IP数为2的32次方,最大客户端端口数为2的16次方,即服务器端单个服务器最大TCP连接数约为2到16次方48次方。这个理论值是挺“满”的,但是服务器肯定承载不了这么大的连接数,主要受限于两个方面:文件描述符,Socket其实就是一个文件,它对应一个文件描述符。Linux下,单个进程打开的文件描述符个数是有限制的,未修改的值一般为1024,但是我们可以通过ulimit来增加文件描述符的个数;系统内存,内核中每个TCP连接都有对应的数据结构,也就是说每个连接都会占用一定的内存;那么如果服务器的内存只有2GB,网卡是千兆的,能不能支持10000个并发请求呢?并发10000个请求,也就是经典的C10K问题,C是Client这个词的首字母缩写,C10K是单机同时处理10000个请求的问题。从硬件资源上看,对于一台2GB内存千兆网卡的服务器,如果每次请求处理占用内存小于200KB,网络带宽100Kbit,可以满足10000个并发请求。但是,要真正实现C10K服务器,必须考虑服务器的网络I/O模型。低效的模型会增加系统开销,离C10K的目标越来越远。多进程模型基于最原始的阻塞网络I/O。如果服务端需要支持多个客户端,比较传统的方式是使用多进程模型,即给每个客户端分配一个进程来处理请求。服务器的主进程负责监听客户端的连接。一旦与客户端的连接完成,accept()函数将返回一个“已连接的套接字”。这时候通过fork()函数创建了一个子进程,而父进程实际上是复制了所有相关的东西,包括文件描述符、内存地址空间、程序计数器、执行的代码等等。当两个进程刚刚复制,它们几乎相同。但是它会根据返回值来区分是父进程还是子进程。如果返回值为0,则为子进程;如果返回值为其他整数,则为父进程。正因为子进程会复制父进程的文件描述符,所以可以直接使用“connectedsocket”与客户端通信。可以发现,子进程不需要关心“监听套接字”,只需要关心“连接套接字”。;父进程则相反,将客户端服务交给子进程,所以父进程不需要关心“连接的Socket”,只需要关心“监听的Socket”。下图描述了从连接请求到连接建立,父进程创建子进程为客户端服务。另外,当“子进程”退出时,进程的一些信息实际上会保留在内核中,同样会占用内存。如果“回收”工作没有做好,就会成为僵尸进程。太多了,会慢慢耗尽我们的系统资源。因此,如果父进程想要??“清理”它的子进程,它的善后工作该如何处理呢?那么子进程退出后回收资源的方式有两种,分别是调用wait()和waitpid()函数。这种用多进程处理多个client的方法,处理100个client还是可行的,但是当client数量达到10000个的时候,就肯定处理不了了,因为每生成一个进程,都会占用一定的时间。系统资源,而且进程间上下文切换的“负担”很重,性能会大打折扣。进程的上下文切换不仅包括虚拟内存、栈、全局变量等用户空间资源,还包括内核栈、寄存器等内核空间资源。多线程模型既然进程间上下文切换的“负担”很重,那么我们来创建一个相对轻量级的模型来处理多用户请求——多线程模型。线程是进程中运行的“逻辑流”。多个线程可以在一个进程中运行,同一个进程中的线程可以共享进程的一些资源,如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享资源不需要在上下文切换时进行切换,而只需要切换线程的私有数据、寄存器等不共享的数据,所以同进程下线程上下文切换的开销比进程小很多。服务端和客户端TCP完成连接后,通过pthread_create()函数创建一个线程,然后将“已连接的Socket”的文件描述符传递给线程函数,然后在线程中与客户端通信,从而实现达到并发处理的目的。如果为每个连接创建一个线程,线程运行后,操作系统不得不销毁该线程。虽然线程切换的开销不大,但是如果频繁创建和销毁线程,对系统的开销还是不小的。那么,我们可以使用线程池来避免频繁创建和销毁线程。所谓线程池就是预先创建若干个线程,这样在建立新的连接时,将连接的Socket放入一个队列中,然后线程池中的线程负责从队列中取出连接的Socket进程进行处理。需要注意的是这个队列是全局的,每个线程都会对它进行操作。为了避免多线程竞争,线程在操作这个队列之前必须加锁。以上是基于进程或者线程模型的,但是还是有问题。当一个新的TCP连接到来时,需要分配一个进程或线程,所以如果要达到C10K,就意味着一台机器需要维护10000个连接,相当于维护10000个进程/线程,即使操作系统死了。活不下去。I/O多路复用既然为每个请求分配一个进程/线程是不合适的,那么是否可以只用一个进程来维护多个Socket呢?答案是肯定的,那就是I/O多路复用技术。虽然一个进程在同一时间只能处理一个请求,但是处理每个请求的事件所需的时间不到1毫秒,从而可以在1秒内处理数千个请求。看时间,多个请求多路复用一个进程,就是多路复用。这种思想很像一个CPU并发运行多个进程,所以也叫时分复用。我们熟悉的select/poll/epoll内核为用户态提供了多路复用系统调用,一个进程可以通过一个系统调用函数从内核中获取多个事件。select/poll/epoll如何获取网络事件?在获取事件的时候,先把所有的连接(文件描述符)传递给内核,然后内核返回产生事件的连接,然后在用户态处理这些连接对应的请求就可以了。select/poll/epoll这三个复用接口,能不能都实现C10K?接下来,我们分别说说。select/pollselect实现多路复用的方式是将所有连接的Socket放入一组文件描述符中,然后调用select函数将这组文件描述符复制到内核中,让内核检查是否有网络事件。检查的方法很粗暴,就是通过遍历文件描述符集,当检测到事件时,将Socket标记为可读或者可写,然后将整个文件描述符集复制回用户态,然后在用户态还需要通过遍历的方式找到可读或可写的Socket,然后进行处理。因此,对于select方法来说,需要两次“遍历”文件描述符集,一次在内核态,一次在用户态,就会有两次“复制”文件描述符集,首先是从用户空间到内核空间,由内核修改,然后发送到用户空间。select使用定长的BitsMap来表示一组文件描述符,支持的文件描述符数量有限。在Linux系统中,受内核中的FD_SETSIZE限制。默认最大值为1024,只能监控0。~1023个文件描述符。poll不再使用BitsMap存储关注的文件描述符,而是使用链表形式组织的动态数组,突破了select对文件描述符个数的限制,当然受限于系统文件描述符。不过poll和select并没有太大的本质区别。它们都使用一个“线性结构”来存储进程关心的Socket集合。因此,两者都需要遍历文件描述符集合,寻找可读或可写的Socket。时间复杂度为O(n),还需要在用户态和内核态之间复制文件描述符集。这样,随着并发数的增加,性能损失会呈指数增长。epollepoll通过两个方面很好的解决了select/poll问题。首先,epoll在内核中使用一颗红黑树来跟踪进程中所有需要检测的文件描述符,通过epoll_ctl()函数将需要监控的socket添加到内核中的红黑树中。红黑树是一种高效的数据结构,一般的增删改查时间复杂度为O(logn)。通过操作这棵黑红树,不需要像select/poll这样每次操作都传入整个socketset,只需要传入一个被检测即可。socket,减少了内核和用户空间的大量数据拷贝和内存分配。第二点,epoll使用了事件驱动机制。内核中维护了一个链表来记录就绪事件。当某个socket上有事件发生时,内核会通过回调函数将其添加到就绪事件列表中。当用户调用epoll_wait()函数时,只会返回有事件的文件描述符个数,不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测效率。从下图可以看出epoll相关接口的作用:即使epoll监听的Socket数量增加,效率也不会大幅度降低,同时可以监听的Socket数量也是非常大,上限是系统定义的进程打开的文件描述符的最大数量。因此epoll被誉为解决C10K问题的利器。顺便说一句,网上很多文章都说epoll_wait返回的时候,对于就绪事件,epoll采用了共享内存的方式,即用户态和内核态都指向就绪链表,这样就避免了内存拷贝消耗。这是错误的!看过epoll内核源码的都知道,根本就没有使用共享内存这回事。从下面的代码可以看出,epoll_wait实现的内核代码调用了__put_user函数,将数据从内核空间复制到用户空间。好了,本篇题外话到此结束,我们继续!epoll支持两种事件触发方式,即边沿触发(ET)和电平触发(LT)。这两个术语很抽象,但它们之间的区别很容易理解。当使用边沿触发模式时,当被监听的Socket描述符上发生可读事件时,服务器只会从epoll_wait中唤醒一次,即使进程没有调用read函数从内核中读取数据,它仍然只会被唤醒一次,所以我们的程序必须保证内核缓冲区中的数据被一次性读取;当使用水平触发方式时,当被监听的Socket上发生可读事件时,服务器会不断从epoll_wait中唤醒,直到内核缓冲区中的数据被读取完毕,read函数读取结束,目的是告诉我们有是要读取的数据;比如你的快递是放在一个快递箱里的,如果快递箱只会短信通知你一次,即使你没有去取,也不会发第二条短信提醒你,这种方式就是边缘触发;如果快递箱发现你的快递没有取出,它会一直发短信通知你,直到你取出快递才会停止,这是横向触发的方式。这就是两者的区别。水平触发是指只要满足事件发生的条件,比如内核中有数据需要读取,事件就会源源不断地传递给用户;而边沿触发是指只有在第一次满足条件时才触发,不会再次传递相同的事件。如果使用水平触发方式,当内核通知文件描述符可读写时,可以继续检测其状态,看是否仍然可读或可写。所以在收到通知后,没必要一次性进行尽可能多的读写操作。如果使用边沿触发方式,I/O事件只会被通知一次,我们不知道能读写多少数据,所以我们应该在接到通知后尽可能多的读写数据,以免错过阅读和写作的机会。因此,我们将在循环中从文件描述符读取和写入数据。如果文件描述符被阻塞,没有数据可读写,进程就会阻塞在读写函数中,程序无法继续执行。因此边沿触发模式一般与非阻塞I/O结合使用,程序会一直执行I/O操作,直到系统调用(如read和write)返回错误,错误类型为EAGAIN或EWOULDBLOCK。一般来说,边沿触发的效率要高于水平触发,因为边沿触发可以减少epoll_wait的系统调用次数,而且系统调用也有一定的开销,毕竟有上下文切换。select/poll只有水平触发方式,epoll默认的触发方式是水平触发方式,但是可以根据应用场景设置为边沿触发方式。此外,在使用I/O多路复用时,最好与非阻塞I/O一起使用。Linux手册中关于select的内容有如下说明:在Linux下,select()可能会报告一个socket文件描述符为“readyforready”,但随后的read会阻塞。例如,这可能发生在数据已到达但检查时校验和错误并被丢弃的情况下。可能存在文件描述符被虚假报告为就绪的其他情况。因此,在不应阻塞的套接字上使用O_NONBLOCK可能更安全。我谷歌翻译的结果:在Linux下,select()可能会报告一个套接字文件描述符为“准备好读取”,而随后的读取块则不会。例如,当数据已经到达但经过检查发现校验和错误并被丢弃时,就会发生这种情况。也有可能在其他情况下文件描述符被错误地报告为就绪。因此,在不应阻塞的套接字上使用O_NONBLOCK可能更安全。简单来说,多路复用API返回的事件不一定是可读可写的。如果使用阻塞I/O,程序在调用read/write时会被阻塞,所以最好使用非阻塞I/O,以应对极少数的特殊情况。总结最基本的TCPSocket编程,它是一个阻塞I/O模型,基本上只有一对一通信,所以为了服务更多的客户端,我们需要改进网络I/O模型。更传统的方式是使用多进程/线程模型。客户端每连接一次,就分配一个进程/线程,然后后续的读写都在对应的进程/线程中。这种方法处理100个客户端是没有问题的。但是当客户端数量增加到10000个时,10000个进程/线程的调度、上下文切换和内存占用就会成为瓶颈。为了解决上述问题,就有了I/O多路复用,可以在一个进程中只处理多个文件的I/O。Linux下提供I/O多路复用的API有3个,分别是:select、poll、epoll。select和poll没有本质区别。它们都在内部使用了一个“线性结构”来存储进程关心的Socket集合。使用时,首先需要通过select/poll系统调用,将相关的Socket集合从用户态复制到内核态,然后由内核检测事件。当网络事件发生时,内核需要遍历进程关注Socket集合并找到对应的Socket,并设置其状态为可读/可写,然后将整个Socket集合从内核态复制到用户态,用户态会不断遍历整个Socket集合,找到可读/可写的Socket,然后进行处理。很明显,select和poll的缺点是当client越多,即Socket集合越大时,Socket集合的遍历和复制会带来很大的开销,所以C10K很难处理。epoll是解决C10K问题的有力工具,它通过两种方式解决了select/poll问题。epoll利用内核中的“红黑树”来关注进程中所有要检测的套接字。红黑树是一种高效的数据结构。一般增删改查的时间复杂度是O(logn)。通过对这棵黑红树的管理,不需要像select/poll这样每次操作都传入整个Socket集合,减少了内核和用户空间的大量数据拷贝和内存分配。epoll使用事件驱动机制。内核中维护了一个“链表”记录就绪事件,只有发生事件的Socket集合才传递给应用程序。不需要像select/poll(包括yes和noEventSocket)那样轮询扫描整个集合,大大提高了检测效率。而且epoll支持边沿触发和水平触发,而select/poll只支持水平触发。一般来说,边缘触发比水平触发更有效。参考资料https://www.zhihu.com/question/39792257https://journey-c.github.io/io-multiplexing/#25-io-multiplexinghttps://panqiincs.me/2015/08/01/io-multiplexing-with-epoll/原文链接:https://mp.weixin.qq.com/s/Qpa0qXxuIM8jrBqDaXmVNA
