
6. 为什么需要 select重点章节第五章已经把问题逼出来了一个线程一次只能阻塞在一个地方但服务器往往要同时关注很多fd。如果继续沿用“等完这个再等那个”的方式线程就总会顾此失彼。所以第六章要回答的问题是一个线程如何同时等待多个连接而不是一次只堵在一个fd上。这一章是整条主线里的一个重点因为它第一次把“阻塞问题”正式推进成了“系统性的解决方案”。6.1select要解决什么问题select不是用来替代 Socket也不是用来替代前面那条通信流程。它要解决的问题只有一个让一个线程同时关注多个fd的状态变化也就是说前面的流程仍然存在服务端还是要socket()、bind()、listen()、accept()客户端还是要socket()、connect()双方还是要recv()/send()select改变的不是流程本身而是线程等待这些fd的方式。6.2 从这个问题会自然想到什么既然一个线程一次只能堵在一个fd上那么一个很自然的想法就是能不能不要让线程先直接阻塞在某个具体连接上而是先统一等待一组fd的状态变化如果这件事能做到那么一个线程同时关注多个fd哪个fd先准备好了就先处理哪个这样线程就不会顾此失彼而是先知道“哪些fd已经就绪”再决定下一步处理谁。这种思路就是后面要说的 I/O 多路复用。6.3 什么叫 I/O 多路复用I/O 多路复用可以先这样理解一个线程同时关注多个fd哪个fd先准备好了就先处理哪个这里的I/O是从抽象层理解为与fd相关的输入输出就绪状态而不是某一个具体函数调用在网络编程里它既包括监听 Socket 上是否有新连接到来(有新连接过来listen_fd表现为可读状态)也包括已连接 Socket 上是否可读、可写。这里的“多路”指的是多个fd这里的“复用”指的是同一个线程并没有为每个连接都单独创建一个阻塞等待而是把等待能力集中起来统一使用。所以select的本质就是把“线程一次只等一个fd”改成“线程一次等多个fd”。6.4select大致是怎么工作的在服务端里select的监听集合通常不是一次设好就不变了而是每一轮循环都重新构造一次。更具体地说程序会先在用户态准备一个fd_set集合把当前关心的fd放进去。最开始程序通常至少会把listen_fd放进去因为服务端始终要继续接收新连接。如果已经存在一些客户端连接那么当前有效的conn_fd也会一起放进这个fd_set。随后程序调用select()把这份用户态准备好的集合交给内核。内核会检查这批fd的状态看哪些已经就绪。当select()返回时用户程序拿到的已经不是原来那份“完整监听集合”而是一份被修改后的结果集合没有就绪的fd会被清掉已经就绪的fd会被保留下来所以select可以先粗略理解成下面这个过程等到select()返回后程序再去检查listen_fd是否就绪哪些conn_fd已经就绪如果listen_fd就绪说明有新连接到来此时可以调用accept()得到新的conn_fd然后在下一轮把这个新的conn_fd也加入监听集合。如果某个conn_fd就绪说明这个连接上已经有数据可读或者已经出现了值得处理的状态变化程序就继续对它执行recv()/send()等操作。所以select()的工作过程可以粗略理解成每一轮循环 - 在用户态重新构造 fd_set - 先把 listen_fd 放进监听集合 - 再把当前所有有效的 conn_fd 放进去 - 调用 select() - 看哪些 fd 已经就绪 如果 listen_fd 就绪 - accept() - 得到新的 conn_fd - 下一轮继续监听它 如果某个 conn_fd 就绪 - 对这个连接进行 recv()/send() 等处理6.5 为什么select能解决第五章的问题第五章的核心矛盾是一个线程只能卡在一个等待点但服务器要同时关注多个连接select的解决方式是不再先对某个具体fd直接recv()或直接accept()而是先统一问内核现在哪些fd已经准备好了这样线程就不会盲目堵在某一个连接上而是先等“就绪事件”再处理已经就绪的fd所以select带来的变化不是“没有等待了”而是等待从“堵在某个具体连接上”变成“统一等待一组fd的状态变化”6.6 在服务端流程里select关注哪些fd最典型的情况是listen_fd所有已经建立好的conn_fd它们在select里的意义不同listen_fd就绪通常表示有新连接到来可以accept()某个conn_fd就绪通常表示这个连接上有数据可读可以recv()这也是为什么前面几章一直强调listen_fd和conn_fd背后都是 Socket但它们承担的职责不同因为到了select这里这种职责差异就会直接体现在处理逻辑上。6.7 为什么说select是最基础的方案select的重要性不只是因为它能用还因为它很适合作为第一步理解 I/O 多路复用。通过它你能先建立三个关键认识问题的核心是“同时等待多个fd”listen_fd和conn_fd需要被放到同一个等待框架里内核和用户程序之间需要配合完成“等待 返回就绪结果”这件事也就是说select不一定是最强的方案但它是最容易看清问题本质的第一种方案。6.8 小结第六章的重点是把第五章的问题正式转化成一个解决方案一个线程一次只能堵在一个 fd 上 - 服务器却要同时关注 listen_fd 和多个 conn_fd - 所以需要一种机制统一等待多个 fd - select 就是最基础的 I/O 多路复用方案 - 它不改变通信流程只改变等待和分发的方式7. 为什么 select 还不够第六章已经解决了一个关键问题一个线程终于可以同时等待多个fd了。但这并不意味着问题就彻底结束了。当连接数量继续增多时select自身的工作方式又会暴露出新的开销。所以第七章要解决的问题是为什么select能用但在高并发场景下还不够好。7.1select已经解决了什么先明确一点select不是没用相反它已经解决了前面最核心的矛盾。它解决的是一个线程不再只盯着一个fdlisten_fd和多个conn_fd可以被放到同一个等待框架里线程可以先等“谁就绪”再决定处理谁所以第七章不是要否定select而是要继续问当fd数量越来越多时select自己会不会变得越来越吃力7.2 为什么连接一多select也会开始吃力select的核心思路没有问题问题出在它每一轮的工作方式并不轻量。连接越多程序每一轮都要做的事情也越多重新构造监听集合把集合传给内核等待结果返回再检查哪些fd就绪了这意味着fd少的时候这套机制很直观fd多的时候这套机制本身也会变成负担也就是说第五章的问题是“一个线程一次只能等一个fd”而第七章的问题变成了一个线程虽然能等很多fd但等的方式开始变得不够高效了7.3select的第一个问题每轮都要重新传入集合在select模型里程序每一轮都要重新构造自己关心的fd集合再把它传给内核由select()在内核里对这批fd进行处理。而且select()返回后还会修改这个集合只保留已经就绪的fd。这就导致下一轮不能直接复用上一次的集合必须重新把listen_fd和所有有效的conn_fd再放进去所以fd越多每一轮在“准备集合 传给内核 由内核处理这批集合”上的开销就越明显。7.4select的第二个问题程序仍然要遍历很多fd即使select()已经在内核里处理完这批fd用户程序拿到结果后在实际处理时通常还是要从自己维护的fd集合里逐个检查。也就是说不是说select()返回了程序就直接只看到一个答案而是程序还要继续遍历判断哪个fd真正就绪所以当连接数量很大时就会出现一种情况真正就绪的fd可能不多但程序仍然要检查很多fd这会让遍历成本越来越明显。7.5select的第三个问题监听数量本身有限制select通常还存在监听数量上限很多环境下默认会受到FD_SETSIZE的限制。这意味着select不是想放多少fd就能放多少当连接数继续增大时它会先碰到规模上的边界所以select不只是“效率慢一点”的问题有时还是“规模本身装不下”的问题。7.6 第七章真正想说明什么到这里问题已经发生了第二次演化一开始的问题 一个线程一次只能等一个 fd select 解决后 一个线程可以同时等多个 fd 新的问题 fd 一多select 自己的工作方式又会变得笨重所以第七章真正想说明的是select已经解决了“能不能同时等多个fd”但没有很好解决“当fd非常多时还能不能高效地等”7.7 为什么这会自然引出epoll一旦看到这里后面的需求就很自然了能不能不要每一轮都重新传整批fd能不能不要每次都从头检查很多fd能不能让内核直接告诉我“哪些fd真的就绪了”这就是第八章epoll要解决的问题。7.8 小结第七章的重点不是说select不好而是说明它的适用边界select 已经解决了“同时等待多个 fd”的问题 - 但它每一轮仍然要重复处理整批 fd - 程序也仍然要遍历很多 fd - 同时还会遇到监听数量限制 - 所以在高并发场景下select 会越来越吃力 - 这就自然引出了 epoll