从接触netty以来,对网络编程中的诸多I/O模型存在困惑,直到最近学习python,才渐渐清晰起来。本文主要梳理一下关于传统多线程模型、多路复用技术以及select、epoll模式多路复用的知识点。
多线程模型
网络编程的基本模型是Client-Server模型,也就网络中两个进程之间相互通信,服务端提供位置信息,客户端向服务端发起连接请求,三次握手成功建立连接之后,双方就可以通过Socket进行通信。
服务端:
1 | from socket import * |
客户端:
1 | from socket import * |
说明:
- 服务端主线程负责监听客户端的连接
- 接收到客户端请求后,便为每一个连接创建一个新的线程
- 处理完成后,关闭连接,销毁线程
这就是典型的一请求一应答模式,当客户端并发访问量增加时,服务端线程数与客户端数将呈1:1的关系增加,这将及其耗费系统的性能。试想有2000个客户端连接时,服务端将创建2000个线程用于处理这些连接,首先要维持这些线程就要耗费大量内存,在做上下文切换的时候可能直接导致系统内存耗尽或者当前进程宕机。那么能不能只使用少量的线程,就可以处理这些连接呢?
单线程非阻塞
python中可以将socket设置为非阻塞,看下面代码:
1 | from socket import * |
上述代码实现了单线程的情况下可以处理多个连接,关键点有以下几点:
- socket不再阻塞当前线程
- 维护了一个list,用来存放可用的socket连接
- 轮询存放socket的集合,处理可读写的socket连接。
这就是一个简单版的I/O多路复用实现。
多路复用(Multiplexing),维基百科上给出的解释是表示在一个信道上传输多路信号或数据流的技术。下图是维基百科给出的模型图:
结合上面的单线程非阻塞例子,可以理解成多个socket连接复用一个线程。
select模式
前面的例子中,使用单线程处理多个socket连接,可以很大程度地节约创建线程和线程切换时带来的系统性能消耗。但同样存在一个问题,由于每次都要轮询所有的socket连接,这将大量耗费CPU时间,而且不是所有的socket都处于就绪状态(连接、读、写),试想轮询了2000个连接结果只有一个scoket在收发包,这无疑浪费了很多CPU性能,那么有没有方法可以得到这些可用的就绪连接,只对这些活跃的连接进行轮询呢?
这里首先要引入一个概念,叫做文件描述符(file descriptor,fd),linux中内核将所有外部设备看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个fd,同样对socket的操作也会有相应 的描述符,描述符是一个数字。
所有socket的文件描述符被放入到一个数组中,前辈们发明了一个系统调用select,select会依次遍历这个数组,如果对应的文件描述符处于就绪状态,就会返回该描述符。如果遍历结束后,仍没有一个可用的fd,它会让当前用户进程睡眠,等到有可用资源的时候再唤醒。
下面是select模式多路复用的简单实现:
1 | from socket import * |
通过select调用可以让用户程序直接获取可用的socket连接,相比较于用程序直接轮询所有socket连接,socket模式在系统内核层面实现,效率极高,而且select基本上所有平台。
但是…
- select单个线程可监视的fd数量存在限制,一般是1024
- ‘If a file descriptor being monitored by select() is closed in another thread, the result is unspecified’,如果别的线程关闭了正在被select监听的fd,结果将是不可预测的…
- select采用轮询的方法,效率极低
那,怎么办!
epoll模式
先来看代码:
1 | import socket |
不同于select,epoll采用事件回调的方式。socket一开始会向epoll注册事件,如果socket变为可用状态,则会触发事件回调,被epoll获取。同时epoll也解决了select单个线程所能监视的fd数量有限的问题。
无论服务端有多少个连接,epoll关心的只是那些活跃的连接,所以epoll的效率较之select也要高出很多。(打个比方,比如说考勤,是每天把大家集中在一起点一下名,看谁没来,还是直接打卡签到快?)
总结
无论是select,还是epoll,I/O多路复用的关键维护了一张fd表。它把多个socket连接的阻塞,转移到单线程如何从众多连接中筛选出可用状态的fd上。至于如何使用这张fd表,是单线程还是交给其他的线程去处理,由具体的实际需要决定了。