文档教程知识库【免费下载链接】CS-Base图解计算机网络、操作系统、计算机组成、数据库共 1000 张图 50 万字破除晦涩难懂的计算机基础知识让天下没有难懂的八股文 在线阅读https://xiaolincoding.com项目地址https://gitcode.com/GitHub_Trending/cs/CS-Base点击查看免费下载导读本文是 CS-Base《图解系统》网络系统章节的核心篇章之一从最基础的 TCP Socket 阻塞模型出发逐步演进到多进程/多线程模型最终深入剖析 select、poll、epoll 三大 I/O 多路复用系统调用的原理、数据结构、触发模式与适用场景。读完本文你将彻底理解 C10K 问题的本质、epoll 为何是解决高并发网络服务的利器、边缘触发与水平触发的区别以及如何用非阻塞 I/O 正确搭配多路复用 API为后续学习 Reactor/Proactor 高性能网络模式 打下扎实基础。从最基本的 Socket 模型说起要想客户端和服务器能在网络中通信必须使用 Socket 编程。它是进程间通信里比较特别的方式特别之处在于它可以跨主机通信。Socket 的中文名叫作插口乍一看还挺迷惑的。事实上双方要进行网络通信前各自得创建一个 Socket这相当于客户端和服务器都开了一个口子双方读取和发送数据的时候都通过这个口子。这样一看就像弄了一根网线一头插在客户端一头插在服务端然后进行通信。创建 Socket 的时候可以指定网络层使用的是 IPv4 还是 IPv6传输层使用的是 TCP 还是 UDP。UDP 的 Socket 编程相对简单些本文只介绍基于 TCP 的 Socket 编程。服务端的 Socket 调用流程服务器的程序要先跑起来然后等待客户端的连接和数据服务端的 Socket 编程过程如下socket()创建调用socket()函数创建网络协议为 IPv4、传输协议为 TCP 的 Socketbind()绑定调用bind()函数给这个 Socket 绑定一个IP 地址和端口。绑定这两个的目的不同绑定端口的目的当内核收到 TCP 报文通过 TCP 头里面的端口号找到我们的应用程序然后把数据传递给我们绑定 IP 地址的目的一台机器可以有多个网卡每个网卡都有对应的 IP 地址当绑定一个网卡时内核在收到该网卡上的包时才会发给我们listen()监听绑定完 IP 地址和端口后调用listen()函数进行监听此时对应 TCP 状态图中的listen状态。如果要判断服务器中一个网络程序有没有启动可以通过netstat命令查看对应的端口号是否有被监听accept()获取连接服务端进入监听状态后调用accept()函数从内核获取客户端的连接如果没有客户端连接则会阻塞等待客户端连接的到来。客户端的连接发起客户端在创建好 Socket 后调用connect()函数发起连接该函数的参数要指明服务端的 IP 地址和端口号然后 TCP 三次握手就开始了。在 TCP 连接的过程中服务器的内核实际上为每个 Socket 维护了两个队列TCP 半连接队列还没完全建立连接的队列里面都是没有完成三次握手的连接此时服务端处于syn_rcvd状态TCP 全连接队列已经建立连接的队列里面都是完成了三次握手的连接此时服务端处于established状态。当 TCP 全连接队列不为空后服务端的accept()函数就会从内核的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序后续数据传输都用这个 Socket。这里有两个关键点值得强调监听的 Socket 和真正用来传数据的 Socket 是两个一个叫作监听 Socket一个叫作已连接 Socket关于这两个队列的实现细节仓库中的 TCP 半连接队列和全连接队列 与 没有 accept能建立 TCP 连接吗 有更深入的实战与内核源码分析。其中可以印证执行accept()只是为了从全连接队列里取出一条连接建立连接的过程本身根本不需要accept()参与三次握手照常进行并顺利建立连接。连接建立后客户端和服务端就开始相互传输数据了双方都可以通过read()和write()函数来读写数据。至此TCP 协议的 Socket 程序的调用过程就结束了。内核视角Socket 与文件描述符不知道你有没有觉得读写 Socket 的方式好像读写文件一样是的基于 Linux 一切皆文件的理念在内核中 Socket 也是以「文件」的形式存在的也是有对应的文件描述符。PS下面会说到内核里的数据结构不感兴趣的可以跳过这一部分不会对后续的内容有影响。文件描述符的作用是什么每一个进程都有一个数据结构task_struct该结构体里有一个指向「文件描述符数组」的成员指针。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符一个整数而数组的内容是一个指针指向内核中所有打开的文件的列表也就是说内核可以通过文件描述符找到对应打开的文件。然后每个文件都有一个 inodeSocket 文件的 inode 指向了内核中的 Socket 结构。在这个结构体里有两个队列分别是发送队列和接收队列这两个队列里面保存的是一个个struct sk_buff用链表的组织形式串起来。sk_buff可以表示各个层的数据包在应用层数据包叫 data在 TCP 层称为 segment在 IP 层叫 packet在数据链路层称为 frame。你可能会好奇为什么全部数据包只用一个结构体来描述呢协议栈采用的是分层结构上层向下层传递数据时需要增加包头下层向上层传递数据时又需要去掉包头。如果每一层都用一个结构体那在层之间传递数据的时候就要发生多次拷贝这将大大降低 CPU 效率。于是为了在层级之间传递数据时不发生拷贝只用sk_buff一个结构体来描述所有的网络包。它通过调整sk_buff中data指针的位置来实现接收报文时从网卡驱动开始通过协议栈层层往上传送数据报通过增加skb-data的值来逐步剥离协议首部发送报文时创建sk_buff结构体数据缓存区的头部预留足够的空间用来填充各层首部在经过各下层协议时通过减少skb-data的值来增加协议首部。如何服务更多的用户—— C10K 问题登场前面提到的 TCP Socket 调用流程是最简单、最基本的它基本只能一对一通信。因为使用的是同步阻塞的方式当服务端还没处理完一个客户端的网络 I/O 时或者读写操作发生阻塞时其他客户端是无法与服务端连接的。可如果服务器只能服务一个客户那太浪费资源了于是要改进这个网络 I/O 模型以支持更多的客户端。在改进前先思考一个问题服务器单机理论最大能连接多少个客户端TCP 连接是由四元组唯一确认的这个四元组就是本机 IP、本机端口、对端 IP、对端端口。服务器作为服务方通常会在本地固定监听一个端口等待客户端的连接。因此服务器的本地 IP 和端口是固定的于是对于服务端 TCP 连接的四元组只有对端 IP 和端口会变化所以最大 TCP 连接数 客户端 IP 数 × 客户端端口数对于 IPv4客户端的 IP 数最多为 2 的 32 次方客户端的端口数最多为 2 的 16 次方也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。这个理论值相当丰满但是服务器肯定承载不了那么大的连接数主要受两个方面的限制文件描述符Socket 实际上是一个文件也就会对应一个文件描述符。在 Linux 下单个进程打开的文件描述符数是有限制的没有经过修改的值一般都是 1024不过可以通过ulimit增大文件描述符的数目系统内存每个 TCP 连接在内核中都有对应的数据结构意味着每个连接都会占用一定内存。那如果服务器的内存只有 2 GB网卡是千兆的能支持并发 1 万请求吗并发 1 万请求就是经典的C10K 问题C 是 Client 单词首字母缩写即单机同时处理 1 万个请求的问题。从硬件资源角度看对于 2 GB 内存千兆网卡的服务器如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。不过要想真正实现 C10K 服务器要考虑的地方在于服务器的网络 I/O 模型——效率低的模型会加重系统开销从而离 C10K 的目标越来越远。多进程模型为每个客户端 fork 一个进程基于最原始的阻塞网络 I/O如果服务器要支持多个客户端其中比较传统的方式就是使用多进程模型也就是为每个客户端分配一个进程来处理请求。服务器的主进程负责监听客户端的连接一旦与客户端连接完成accept()函数就会返回一个「已连接 Socket」这时就通过fork()函数创建一个子进程实际上就把父进程所有相关的东西都复制一份包括文件描述符、内存地址空间、程序计数器、执行的代码等。这两个进程刚复制完的时候几乎一模一样。不过会根据返回值来区分是父进程还是子进程如果返回值是 0则是子进程如果返回值是其他的整数就是父进程。正因为子进程会复制父进程的文件描述符于是就可以直接使用「已连接 Socket」和客户端通信了。可以发现子进程不需要关心「监听 Socket」只需要关心「已连接 Socket」父进程则相反将客户服务交给子进程处理因此不需要关心「已连接 Socket」只需要关心「监听 Socket」。另外当「子进程」退出时实际上内核里还会保留该进程的一些信息也是会占用内存的。如果不做好回收工作就会变成僵尸进程随着僵尸进程越来越多会慢慢耗尽系统资源。因此父进程要善后好自己的孩子有两种方式可以在子进程退出后回收资源调用wait()和waitpid()函数。这种用多个进程来应付多个客户端的方式在应对 100 个客户端时还是可行的但是当客户端数量高达一万时肯定扛不住因为每产生一个进程必会占据一定的系统资源而且进程间上下文切换的包袱很重性能会大打折扣。进程的上下文切换不仅包含虚拟内存、栈、全局变量等用户空间的资源还包括了内核堆栈、寄存器等内核空间的资源。多线程模型更轻量级的并发方案既然进程间上下文切换的包袱很重那我们就搞个比较轻量级的模型来应对多用户的请求——多线程模型。线程是运行在进程中的一个逻辑流单进程中可以运行多个线程同进程里的线程可以共享进程的部分资源比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等。这些共享资源在上下文切换时不需要切换而只需要切换线程的私有数据、寄存器等不共享的数据因此同一个进程下的线程上下文切换的开销要比进程小得多。当服务器与客户端 TCP 完成连接后通过pthread_create()函数创建线程然后将「已连接 Socket」的文件描述符传递给线程函数接着在线程里和客户端进行通信从而达到并发处理的目的。如果每来一个连接就创建一个线程线程运行完后操作系统还得销毁线程。虽说线程切换的上下文开销不大但频繁创建和销毁线程系统开销也不小。那么可以使用线程池的方式来避免线程的频繁创建和销毁。所谓的线程池就是提前创建若干个线程当有新连接建立时将这个已连接的 Socket 放入一个队列里然后线程池里的线程负责从队列中取出已连接 Socket 进行处理。需要注意的是这个队列是全局的每个线程都会操作为了避免多线程竞争线程在操作这个队列前要加锁。上面基于进程或线程的模型其实还是有问题新到来一个 TCP 连接就需要分配一个进程或线程。如果要达到 C10K意味着要一台机器维护 1 万个连接相当于要维护 1 万个进程/线程操作系统就算死扛也是扛不住的。I/O 多路复用一个进程维护多个 Socket既然为每个请求分配一个进程/线程的方式不合适那有没有可能只使用一个进程来维护多个 Socket 呢答案是有的那就是I/O 多路复用技术。一个进程虽然任意时刻只能处理一个请求但是处理每个请求的事件时耗时控制在 1 毫秒以内这样 1 秒内就可以处理上千个请求。把时间拉长来看多个请求复用了同一个进程这就是多路复用。这种思想很类似一个 CPU 并发多个进程所以也叫做时分多路复用。我们熟悉的select/poll/epoll是内核提供给用户态的多路复用系统调用进程可以通过一个系统调用函数从内核中获取多个事件。它们是如此重要以至于仓库中后续的 Reactor 和 Proactor 高性能网络模式 也是建立在这一技术之上的——先通过多路复用接口拿到就绪事件再以事件分发的方式驱动业务处理。select/poll/epoll是如何获取网络事件的呢在获取事件时先把所有连接文件描述符传给内核再由内核返回产生了事件的连接然后在用户态中再处理这些连接对应的请求即可。这三个多路复用接口都能实现 C10K 吗接下来分别说说它们。select 与 poll线性结构 全量遍历拷贝select 的工作方式select 实现多路复用的方式是将已连接的 Socket 都放到一个文件描述符集合然后调用select()函数将文件描述符集合拷贝到内核里让内核来检查是否有网络事件产生。检查的方式很粗暴就是通过遍历文件描述符集合的方式当检查到有事件产生后将此 Socket 标记为可读或可写接着再把整个文件描述符集合拷贝回用户态里然后用户态还需要再通过遍历的方法找到可读或可写的 Socket然后再对其处理。所以对于 select 这种方式需要进行2 次「遍历」文件描述符集合一次在内核态里一次在用户态里而且还会发生2 次「拷贝」文件描述符集合先从用户空间传入内核空间由内核修改后再传出到用户空间。select 使用固定长度的BitsMap表示文件描述符集合而且所支持的文件描述符的个数是有限制的。在 Linux 系统中由内核中的FD_SETSIZE限制默认最大值为1024只能监听 0~1023 的文件描述符。poll 的改进与局限poll 不再用 BitsMap 来存储所关注的文件描述符取而代之的是用动态数组以链表形式来组织突破了 select 的文件描述符个数限制当然还会受到系统文件描述符限制。但是 poll 和 select 并没有太大的本质区别都是使用「线性结构」存储进程关注的 Socket 集合因此都需要遍历文件描述符集合来找到可读或可写的 Socket时间复杂度为 O(n)而且也需要在用户态与内核态之间拷贝文件描述符集合。这种方式随着并发数上来性能的损耗会呈指数级增长。epoll红黑树 就绪事件链表epoll 通过两个方面很好地解决了 select/poll 的问题。第一点epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述符。把需要监控的 socket 通过epoll_ctl()函数加入内核中的红黑树里。红黑树是个高效的数据结构增删查一般时间复杂度是O(logn)。通过对这棵红黑树进行操作就不需要像 select/poll 每次操作时都传入整个 socket 集合只需要传入一个待检测的 socket减少了内核和用户空间大量的数据拷贝和内存分配。第二点epoll 使用事件驱动的机制内核里维护了一个链表来记录就绪事件。当某个 socket 有事件发生时通过回调函数内核会将其加入到这个就绪事件列表中。当用户调用epoll_wait()函数时只会返回有事件发生的文件描述符的个数不需要像 select/poll 那样轮询扫描整个 socket 集合大大提高了检测的效率。epoll 的方式即使监听的 Socket 数量越多效率也不会大幅度降低能够同时监听的 Socket 的数目也非常多上限就为系统定义的进程打开的最大文件描述符个数。因而epoll 被称为解决 C10K 问题的利器。辟谣epoll 并没有使用共享内存插个题外话网上不少文章说epoll_wait返回时对于就绪的事件epoll 使用的是共享内存的方式即用户态和内核态都指向了就绪链表所以就避免了内存拷贝消耗。这是错的看过 epoll 内核源码的都知道压根就没有使用共享内存这个玩意。epoll_wait实现的内核代码中调用了__put_user函数这个函数就是将数据从内核拷贝到用户空间。因此就绪事件仍然存在一次从内核态到用户态的数据拷贝。边缘触发ET与水平触发LTepoll 支持两种事件触发模式分别是边缘触发edge-triggeredET和水平触发level-triggeredLT。这两个术语还挺抽象的其实它们的区别还是很好理解的边缘触发模式当被监控的 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 多路复用时最好搭配非阻塞 I/O 一起使用。Linux 手册关于 select 的内容中有如下说明Under Linux, select() may report a socket file descriptor as ready for reading, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.翻译过来的大意是在 Linux 下select() 可能会将一个 socket 文件描述符报告为准备读取而后续的读取却发生阻塞。例如当数据已经到达但经检查后发现有错误的校验和而被丢弃时就会发生这种情况。也有可能在其他情况下文件描述符被错误地报告为就绪。因此在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全。简单点理解就是多路复用 API 返回的事件并不一定可读写的。如果使用阻塞 I/O那么在调用 read/write 时则会发生程序阻塞因此最好搭配非阻塞 I/O以便应对极少数的特殊情况。实战验证用 Linux 命令观测连接队列与网卡特性理解原理之后可以结合仓库中相关章节给出的命令做实际观测这里整理三个与本文强相关的实战验证点1. 用netstat验证服务是否在监听。服务端调用listen()后可以通过netstat查看对应端口号是否处于监听状态这是判断一个网络程序有没有启动的常用手段。仓库中 如何查看网络的性能指标 一节还介绍了netstat与性能更好的ss命令的用法对比。2. 用ss观测全连接队列。在执行listen()之后、accept()取出连接之前全连接队列的状态可以用ss -lnt查看Send-Q是全连接队列的最大长度Recv-Q是当前全连接队列的使用值。当两者数值很接近时说明全连接队列可能已经满了此时内核会丢弃第三次握手的 ACK 包或返回 RST 包。详细的队列溢出排查命令见仓库中的 TCP 半连接队列和全连接队列 与 没有 accept能建立 TCP 连接吗。3. 用ulimit查看文件描述符上限。单进程可打开的文件描述符数默认一般是 1024这正是 select 的FD_SETSIZE限制值通过ulimit -n可以查看、通过ulimit命令可以增大这也是 epoll 能够支撑海量连接的前提之一。总结一张表看懂三种多路复用 API最基础的 TCP Socket 编程是阻塞 I/O 模型基本上只能一对一通信。为了服务更多客户端需要改进网络 I/O 模型。比较传统的方式是使用多进程/线程模型——每来一个客户端连接就分配一个进程/线程后续的读写都在对应的进程/线程中。这种方式处理 100 个客户端没问题但当客户端增大到 10000 个时10000 个进程/线程的调度、上下文切换以及它们占用的内存都会成为瓶颈。为了解决上面这个问题就出现了 I/O 多路复用可以只在一个进程里处理多个文件的 I/O。Linux 下有三种提供 I/O 多路复用的 APIselect、poll、epoll。它们的关键差异可以总结如下对比维度selectpollepoll内部数据结构固定长度 BitsMap动态数组链表组织红黑树 就绪事件链表文件描述符上限FD_SETSIZE限制默认 1024无固定上限受系统 FD 限制系统定义的进程最大打开 FD 个数检测方式内核态遍历 用户态遍历O(n)内核态遍历 用户态遍历O(n)事件驱动回调epoll_wait只返回就绪 FD集合传递每次全量拷贝进出内核每次全量拷贝进出内核epoll_ctl增量注册无需全量拷贝触发模式仅水平触发仅水平触发水平触发默认 边缘触发应对 C10K困难困难利器select 和 poll 并没有本质区别它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。在使用的时候首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态然后由内核检测事件当有网络事件产生时内核需要遍历进程关注的 Socket 集合找到对应的 Socket并设置其状态为可读/可写然后把整个 Socket 集合从内核态拷贝回用户态用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket然后对其处理。很明显select 和 poll 的缺陷在于当客户端越多、Socket 集合越大时Socket 集合的遍历和拷贝会带来很大的开销因此很难应对 C10K。epoll 是解决 C10K 问题的利器通过两个方面解决了 select/poll 的问题epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket。红黑树是高效的数据结构增删查一般时间复杂度是 O(logn)。通过对这棵红黑树的管理不需要像 select/poll 在每次操作时都传入整个 Socket 集合减少了内核和用户空间大量的数据拷贝和内存分配epoll 使用事件驱动的机制内核里维护了一个「链表」来记录就绪事件只将有事件发生的 Socket 集合传递给应用程序不需要像 select/poll 那样轮询扫描整个集合包含有事件和无事件的 Socket大大提高了检测的效率。而且epoll 支持边缘触发和水平触发的方式而 select/poll 只支持水平触发。一般而言边缘触发的方式会比水平触发的效率高因为可以减少epoll_wait的系统调用次数。掌握 I/O 多路复用之后可以继续深入仓库中同属网络系统章节的 高性能网络模式Reactor 和 Proactor——它对 I/O 多路复用做了面向对象的封装Redis、Nginx、Netty、Memcache 等开源软件正是基于这些模式实现了高并发服务。赞分享文档教程知识库【免费下载链接】CS-Base图解计算机网络、操作系统、计算机组成、数据库共 1000 张图 50 万字破除晦涩难懂的计算机基础知识让天下没有难懂的八股文 在线阅读https://xiaolincoding.com项目地址https://gitcode.com/GitHub_Trending/cs/CS-Base点击查看免费下载相关推荐CS-NotesSocket 五大 I/O 模型与 select、poll、epoll 多路复用机制详解CS NotesSocket 五大 I/O 模型与 select、poll、epoll 多路复用机制详解 本文基于 CS Notes 仓库中的 notes/S知识库文档教程终极指南如何在keras-adversarial框架中自定义对抗训练策略终极指南如何在keras adversarial框架中自定义对抗训练策略 keras adversarial是一个基于Keras的生成对抗网络框架它提供了灵Python 异步 Socket I/O 实战从 select 到 epoll/kqueue 再到 selectors 的 I/O 多路复用完整指南Python 异步 Socket I/O 实战从 select 到 epoll/kqueue 再到 selectors 的 I/O 多路复用完整指南 在 py文档教程开发工具上一篇Salt Thorium status 模块实战用 status.reg 寄存器追踪 Minion 健康状态并自动清理失联密钥下一篇告别卡顿与乱码WarcraftHelper魔兽争霸优化工具5步上手指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考