网络编程的初印象
现在的软件,单机模型的越来越少了,更多的是C/S模型,这个模型之间的通信一般是通过socket技术来实现。而socket又涉及到TCP/IP协议,这也就是今天我们主题,基于TCP/IP协议的网络编程。
对于TCP/IP协议的理论学习,这里推荐的是Richard 的 《TCP/IP 详解卷1:协议》和《UNIX 网络编程卷1:套接口 API》(俗称 UNP1)。
我们先看一个场景问题:一个server如何应对成百上千个client。
一个最简单的模型就是用一个进程来处理所有的client连接。但是回头想想,在处理过程中,如果有上百个连接同时请求服务,下一个连接首先要等待上一个连接处理完(同步)。这个在处理上的连接很有可能阻塞在数据操作(I/O)上,这样处理连接的效率之差及client响应之慢是几乎是所有人不能接受的。
为了提高效率,我们做一点改进,对每一个client连接产生一个线程(windows)或一个进程(linux)来处理,如果忽略线程或进程的上下文切换损耗,就单单看产生的成百上千个线程和进程的可行性,对不起,操作系统是有线程或进程的资源上限。
为了解决线程资源频繁切换造成的资源损耗和资源数限制问题,我们再给予改进。我们采用一个线程池来处理部分连接,其他连接排队等候。那么问题又来了,因为一个机器的CPU不多,同时能处理的也就那么几个连接,响应效率和处理效率依然提不上去。
其实网络耗时一般都是在数据操作(I/O)上,一个输入操作通常包括两个不同的阶段:
- 等待数据准备好;
- 从内核向进程复制数据;
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
很幸运的是,我们出生在这个时代,这些网络编程的效率问题,计算机前辈们已经帮我们想好了。就是我们网络编程里经典的Reactor(又称异步阻塞IO、IO多路复用) 和 Proactor(又称异步非阻塞IO) 两种并发编程模式。他们都是基于事件驱动的,我们就是把网络中需要处理的事件注册到事件管理器中(比如网络行为事件,IO 操作事件…..),然后等事件状态就绪了,它就用回调的方式通知我们去处理。这样的话,只用一个线程就可以处理几乎所有的事件,而且CPU也不会闲着。
现在来简单介绍一下Unix下服务器端常用的5种I/O模型:
- 同步阻塞式I/O;
- 同步非阻塞式I/O;
- I/O多路复用;
- 信号驱动式I/O;
- 异步I/O;
1、 同步阻塞式I/O
默认情况下,所有套接字都是阻塞的,以数据报套接字作为例子,如图:
用户空间下的应用进程调用recvform,其中系统调用直到内核的数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回。最常见有错误是系统调用被信号中断。我们说的进程从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。
2、 同步非阻塞式I/O
当把一个套接字设置成非阻塞,是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。我们来看一个例子:
前三次调用recvfrom时,没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回,用户空间应用进程继续处理。
当一个应用程序像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间。
3、 I/O多路复用
有了I/O复用,我们就可以调用select或poll,阻塞在这两个系统调用的某一个之上,而不是阻塞在真正的I/O系统调用上,如图:
我们阻塞于select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区中。使用select的优势在于我们可以等待多个描述符就绪。
4、 信号驱动式I/O
我们也可以使用信号,让内核在描述符就绪时发送SIGIO信号通知我们,如图:
我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信息处理函数。该系统调用立即返回,我们的进程继续工作,也就是他没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGNO信号。随后即可以在信号处理函数中调用recvfrom函数读取数据报,又通知主循环已准备好待处理,也可以立即通知主循环,让它读取数据报。
这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来处信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
5、 异步I/O
异步I/O的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。如图所示:
我们调用aio_read函数,给内核传递描述符、缓冲区指针、缓冲区大小和文件偏移,并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待I/O完成期间,我们进程不被阻塞。该例子要求内核在操作完成时产生某个信号,该信号直到数据已复制到应用进程缓冲区才产生,这一点不同于信号驱动式I/O模型。
可以看出,前4种模型的主要区别在于第一阶段,因为它们的第二阶段是一样的:在数据从内核复制到调用者的缓冲区期间,进程阻塞于recvfrom调用。相反异步I/O模型在这两个阶段都要处理,从而不同于其他4种模型。以下表格是5种模型的比较:
备注:
同步与异步,描述的是用户线程与内核的交互方式
同步:用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;
异步:用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞与非阻塞,描述的是用户线程调用内核IO操作的方式
阻塞:IO操作需要彻底完成后才返回到用户空间;
非阻塞:IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。
线程池:在还没有任务到来之前,创建一定数量的线程,放入空闲队列中。这些线程都是处于睡眠状态,不消耗CPU,而只是占用较小的内存空间。当请求到来之后,缓冲池给这次请求分配一个空闲线程,把请求传入此线程中运行,进行处理。当预先创建的线程都处于运行状态,即预制的线程不够用,线程池可以自由创建一定数量的新线程,用于处理更多的请求。当系统比较闲的时候,也可以通过移除一部分一直处于停用状态的线程。