网络 IO 模型发展史
网络 IO 模型进化史
整个网络 IO 模型的发展是从阻塞 IO开始、中间逐步演变成非阻塞 IO、基于非阻塞 IO 的多路复用模型。其中多路复用又是从 select 发展到 poll 最后到 epoll 形式的。
阻塞 IO
就是一直等着,直到拿到数据之后才继续往下执行。
客户端和服务端建立好连接之后,服务端读取客户端发送过来的数据。从 read
开始就一直等着,直到读取到数据才返回,继续往下执行。
这种方式的缺点就是,一个线程只能处理一个连接。如果我想处理多个客户端的连接,就只能多开线程。虽然线程是轻量的,但是当我有很多个连接的时候,线程开销会非常大。
那能不能不让 read
等待呢?能不能调用了之后让它直接返回?这就是非阻塞 IO。
非阻塞 IO
首先来看一下服务端是怎么一步一步把数据给到用户态的。
- 客户端发送数据给服务端
- 网卡收到客户端发来的数据
- 网卡等着数据全部发过来之后,将数据拷贝到内核
- 内核等着数据拷贝完成后,将数据拷贝给用户态
- 用户态等待数据全部拷贝完成
整个过程也就是 客户端 -> 网卡 -> 内核 -> 用户态。
那为了能够让 read
直接返回而不用阻塞等待,就可以在数据拷贝到内核之前,直接返回一个是否就绪的状态。如果内核还没有收到全部的数据,就返回一个未就绪的状态,如果数据已经准备好了,就返回一个文件描述符,这样就可以把数据读到用户态了。
也就是:
- 内核没准备好 return -1
- 内核准备好了 return 文件描述符
这样用户态上就不用一直等着了。怎么根据这个返回值解决一个线程只能处理一个连接的问题呢?
我可以维护一个列表,这个列表里面放的是连接对象。然后单独开一个线程,不断轮询这个列表中的连接,当有连接的 read
就绪了就处理,没有就绪就会遍历下一个连接。这样当有新的连接来的时候,就可以直接把这个连接放到列表里面。就实现了一个线程里面处理多个连接的操作。
但是这样有什么缺点呢?轮询。当连接非常多的时候,轮询效率是很低的,而且每一次遍历连接,都会执行系统调用,进行用户态和内核态之间的上下文切换,开销很大。
既然这样,我干脆把轮询操作放到内核态去,让内核帮我去轮询,多好。于是就有了 IO 多路复用模型。
基于非阻塞 IO 的多路复用
select
将轮询操作放到内核态。用户态只要传一个连接列表给到内核就可以了,内核去轮询连接状态。当某次轮询中有连接就绪后,遍历完然后返回这个列表里面就绪的个数。用户态上依然要去遍历这个连接列表去读数据。这里注意 select
操作是阻塞的,之所以说是基于非阻塞 IO 的多路复用,我觉得是因为它是从非阻塞 IO 的基础上发展来的。
这样有什么缺点呢?
- 复制。要把连接列表复制到内核去,为什么不直接复用呢?复制的话连接很多的时候很消耗性能。
- 轮询。内核还是要去轮询。轮询过程是同步的,为什么不改成异步的,当就绪后发送一个事件通知内核?
- select 的返回是就绪个数。为什么不直接返回已经就绪的文件描述符?
为了解决这些问题,就有了 poll 和 epoll。
poll
和 select 的主要区别是,去掉了 select 只能监听 1024 个文件描述符的限制。
epoll
解决了复制、轮询、返回值的问题。
- 复制问题:在内核层面上维护一个文件集合,用户层面只需要告诉内核怎么修改这个集合就行了。
- 轮询问题:改成了异步事件通知的方式。通过异步 IO 事件唤醒。
- 返回值的问题:通过操作一个红黑树结构返回已就绪的文件描述符。
总结
我觉得整个发展历程,就是从原来的只能操作单个连接,变成了批量操作多个连接。
而且减少了用户层面上的操作复杂度,虽然到最后用户层面上看起来是同步阻塞的(这种同步是符合人的心智的),但是内核对所有的连接会进行异步处理。
减少了系统调用的次数,开销变小。
这里分享 https://www.cnblogs.com/flashsun/p/14591563.html 的一个例子帮助理解。
就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。