对于一个套接字的I/O通信,他会涉及到两个系统对象,一个是调用这个io的进程或者线程,还有一个就是系统内核。比如当一个读操作发生时,他会经历两个阶段:
1.等待数据的准备
2.将准备好的数据从内核拷贝到进程中
阻塞:
在linux中,所有的socket都是阻塞的。在调用io时,首先会阻塞在数据的准备中,当数据准备好以后,一会阻塞在数据的拷贝过程中。所以,阻塞io其实阻塞了两个阶段。
非阻塞:
非阻塞相对于阻塞来说,在数据准备的阶段,并不会再去阻塞等待了,而是直接返回结果。但是仍然会在数据的拷贝过程中阻塞住。我们可以采用忙轮询的方式主动向内核询问是否已经准备好数据,我们经常使用的select就是这样的
同步:
所谓同步,就是当我们调用一个函数时,必须要等待这个函数的调用结果返回之后,才可以执行下一步,相当于是顺序执行我们写好的逻辑
异步:
异步和同步相对。
并发与迭代
套接字编程经常用于客户端/服务端模型中。
迭代服务器模型和并发服务器模型是socket编程中最常见使用的两种编程模型
通常来说大多数TCP服务器是并发的,大多数UDP服务器是迭代的
迭代服务器
迭代服务器逻辑就是,服务器一次只处理一个客户端连接,并且必须要处理完之后才能继续处理下一个。这种模型导致的问题是,如果前一个客户端处理一直没完成,导致之后的连接无法处理了。因此最好要设置超时时间。
并发服务器
并发服务器,主要区别在于每次一个新客户端连接到来时,父进程都会fork出一个子进程或者子线程,来单独处理客户端的连接。而且子进程和父进程是共享这个socket描述符的,实际上子进程和父进程是共享listenfd和connfd的,并各自维护一个计数器,初始值为2,因此只有当父进程和子进程各自关闭变成0了,才会释放资源。
常见的I/O模型
阻塞I/O
使用socket函数创建套接字,默认都是阻塞的。但是并不是所有以阻塞模式创建的套接字调用函数时都是阻塞的,比如bind和listen函数就会直接返回。
1.输入操作: recv()、recvfrom()函数。以阻塞套接字为参数调用该函数接收数据。如果此时套接字缓冲区内没有数据可读,则调用线程在数据到来前一直睡眠。
2.输出操作: send()、sendto()函数。以阻塞套接字为参数调用该函数发送数据。如果套接字缓冲区没有可用空间,线程会一直睡眠,直到有空间。
3.接受连接:accept()。以阻塞套接字为参数调用该函数,等待接受对方的连接请求。如果此时没有连接请求,线程就会进入睡眠状态。
4.外出连接:connect()函数。对于TCP连接,客户端以阻塞套接字为参数,调用该函数向服务器发起连接。该函数在收到服务器的应答前,不会返回。这意味着TCP连接总会等待至少到服务器的一次往返时间。
以上四种操作会进行阻塞。
这种编程模型的缺点就是效率很低,只能一对一的处理客户端请求。因此我们可以使用多线程的方式来处理,每一个客户端连接对应一个进程或者线程。但这种方式也会造成资源的大量浪费,比如一个线程会一直空等待客户端的请求,浪费了资源。可以采用线程池的方式减轻这种浪费,但本质上不能解决资源的浪费。
非阻塞I/O
非阻塞I/O再发起一个read操作之后并不需要等待,而是直接返回得到了一个结果。但这个结果可能是错误的,也可能是正确的数据。用户进程可以拿到这个结果进行判断,如果是错误的,那么可以继续发送read操作给内核,当内核此时将数据准备完成并且收到了用户进程的调用,就会将数据拷贝到用户进程。但这种方式会浪费大量资源,因为一个线程会一直轮询是否完成这个操作。所以不建议使用,而select很好的改善了这个问题,因为它可以一次处理多个请求。
多路复用I/O
IO multiplexing这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和blocking IO的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而blocking IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。所以,如果处理的连接数不是很高的话,使用select/epoll的server不一定比使用multi-threading + blocking IO的server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。因此select()与非阻塞IO类似。
大部分Unix/Linux都支持select函数,该函数用于探测多个文件句柄的状态变化。
下面给出select接口的原型:
FD_ZERO(int fd, fd_set fds)
FD_SET(int fd, fd_set fds)
FD_ISSET(int fd, fd_set fds)
FD_CLR(int fd, fd_set fds)
int select(int nfds, fd_set readfds, fd_set writefds, fd_set exceptfds,
struct timeval timeout)
这里,fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set 中标记一个值为16的句柄,则该fd_set的第16个bit位被标记为1。具体的置位、验证可使用 FD_SET、FD_ISSET等宏实现。在select()函数中,readfds、writefds和exceptfds同时作为输入参数和输出参数。如果输入的readfds标记了16号句柄,则select()将检测16号句柄是否可读。在select()返回后,可以通过检查readfds有否标记16号句柄,来判断该“可读”事件是否发生。另外,用户可以设置timeout时间。
该模型只是描述了使用select()接口同时从多个客户端接收数据的过程;由于select()接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统。如下图。
这里需要指出的是,客户端的一个 connect() 操作,将在服务器端激发一个“可读事件”,所以 select() 也能探测来自客户端的 connect() 行为。
上述模型中,最关键的地方是如何动态维护select()的三个参数readfds、writefds和exceptfds。
作为输入参数,readfds应该标记所有的需要探测的“可读事件”的句柄,其中永远包括那个探测 connect() 的那个“母”句柄;同时,writefds 和 exceptfds 应该标记所有需要探测的“可写事件”和“错误
事件”的句柄 ( 使用 FD_SET() 标记 )。
作为输出参数,readfds、writefds和exceptfds中的保存了 select() 捕捉到的所有事件的句柄值。程序员需要检查的所有的标记位 ( 使用FD_ISSET()检查 ),以确定到底哪些句柄发生了事件。
上述模型主要模拟的是“一问一答”的服务流程,所以如果select()发现某句柄捕捉到了“可读事件”,服务器程序应及时做recv()操作,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入writefds,准备下一次的“可写事件”的select()探测。同样,如果select()发现某句柄捕捉到“可写事件”,则程序应及时做send()操作,并准备好下一次的“可读事件”探测准备。
这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。
相比其他模型,使用select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。
但这个模型依旧有着很多问题。首先select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。
如果需要实现更高效的服务器程序,类似epoll这样的接口更被推荐。遗憾的是不同的操作系统特供的epoll接口有很大差异,所以使用类似于epoll的接口实现具有较好跨平台能力的服务器会比较困难。
其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。
单个庞大的执行体1的将直接导致响应其他事件的执行体迟迟得不到执行,并在很大程度上降低了事件探测的及时性。
幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent库,还有作为libevent替代者的libev库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择
信号驱动I/O
异步I/O