文章目录
- 1.循环式/迭代式服务器
- 2.并发式(concurrent)服务器
- 3.prefork or pre threaded(UNP 第27章)
- 4.反应式( reactive )服务器 (reactor模式)(select/poll/epoll)
- 5.reactor+thread per request(过渡方案)
- 6.reactor+worker thread
- 7.reactor+worker thread pool(能适应密集计算)
- 8.multiple reactors(能适应更大的突发I/O)
- 9.multiple reactors+thread pool(one loop per thread+threadpool)(突发I/O与密集计算)
- 10.proactor服务器(proactor模式,基于异步I/O)
- 11.几个问题
1.循环式/迭代式服务器
- 特点:无法充分利用多核CPU,不适合执行时间较长的服务,即适用于短连接(这样可以处理多个客户端)
- 图片如下:
解释:
(1)如果是长连接则需要在read/write之间循环,那么只能服务一个客户端。
(2)循环式服务器只能使用短连接,而不能使用长链接,否则无法处理多个客户端的请求,因为整个程序是一个单线程的应用程序,如果a的处理请求不能断开(使用长链接)
那么下一个客户端的请求将不能连接上。因为是单线程的也无法充分利用多核CPU。
2.并发式(concurrent)服务器
-
特点:
(1)one connection per process/one connection per thread
(2)适合执行时间比较长的服务 -
图片如下:
解释:
(1)若采用多进程方式
fork之后,父进程需要关闭已连接socket,子进程需要关闭监听socket;
子进程在处理客户端的请求,父进程关闭已连接套接字,等待下一个客户端请求连接,所以可以并发式(concurrent)服务器处理多个客户端的请求,一个客户端一个进程,而且,子进程是长连接的,不断的处理请求,即使子进程中解包,计算,打包的过程时间过长,也不会影响父进程去连接其他客户端的请求。
(2)若采用多线程方式
则不需要关闭什么socket;
主线程每次accept 回来就创建一个子线程服务,由于线程共享文件描述符,故不用关闭。
3.prefork or pre threaded(UNP 第27章)
- 图例如下:
解释:
(1)容易发生“惊群”现象,客户端连接过来的时候,即多个子进程都处于accept状态,只有一个进程返回值是正确的,其他进程返回失败了。
还有一个是惊群问题:当一个客户端连接过来的时候,由于多个子进程都处于accept状态,这种服务器由于多个进程在accept等待中,当一个请求到达时,都会被触发,都会返回,但是只有一个成功返回。 这是一种“惊群”现象,有多个进程同时等待网络的连接事件,当这个事件发生时,这些进程被同时唤醒,就是“惊群”,惊了一群。唤起多余的进程间影响服务器的性能(仅有一个服务进程accept成功,其他进程被唤起后没抢到“连接”而再次进入休眠)。这样会影响性能
如何解决惊群现象?
在unix网络编程一书中的27.9节提到,就是由父进程调用accept,而子进程不调用accept, 然后由父进程将已经连接好的描述符套接字传递给子进程,这样就绕过了 处于accept状态的子进程全都被唤醒,这种技术比较复杂,父进程必须跟踪子进程的忙闲状态,以便给空闲子进程传递描述符套接字。
(2)预先创建进程或者预先创建线程
一个进程提前fork了若干个子进程,每个子进程负责和客户端的通信,每个子进程执行的也是上图右边的步骤。前面的创建套接字,绑定端口号,监听套接字这些步骤,每个预先创建的线程已经完成,他们分别调用accept并由内核置入睡眠状态。
缺点在于:每次启动服务器,父进程必须猜测到底需要预先派生多少子进程,还有一个就是如果不考虑再派生子进程,先前派生的子进程可能被客户请求占用完,以后新到的请求只能先完成三次握手,并且达到listen接口的最大并发连接数backlog, 直到有子进程可用。服务器才调用accept,将这些已经完成的连接传递给accept。
4.反应式( reactive )服务器 (reactor模式)(select/poll/epoll)
-
muduo支持
-
解释
select/poll/epoll就可以实现反应式reactor服务器,即:IO多路复用。
首先将监听socket加入到reactor中,关注其可读事件;
客户端过来的时候,监听socket发生了可读事件,acceptor调用accept接受连接;
接受连接就返回到已连接socket,就将其加入到reactor中,关注其可读事件,已连接socket若发生了可读事件,那么就开始分发dispatch这些事件,读取read请求包,decode解包,计算处理compute,打包encode,最后发送send;
这些任务都是在一个线程完成的,单线程轮询;
-
优缺点:
(1)比并发服务器的并发量高(并发服务器创建的进程数量和线程数量有限),但是不能重复利用多CPU
(2)并发处理多个请求,实际上是在一个线程中完成。也就是单线程轮询多个客户端无法充分利用多核CPU
不适合执行时间比较长的服务,所以为了让客户感觉是在“并发”处理而不是“循环”处理,每个请求必须在相对较短时间内执行。当然如果这个请求不能在有限的时间内完成我们可以将这个请求拆分开来,使用有限状态机机制来完成。不过因为有比这种服务器更好的方案,现实生活中即使可以使用有限状态机,也不使用这种服务器。
5.reactor+thread per request(过渡方案)
- 每个请求过来,创建一个线程。这样就能利用多核CPU
- 不好
6.reactor+worker thread
- 即:事件循环机制加上一个工作的线程
- 每一个连接在一个工作的线程中完成,也能利用多核CPU,这种模式是一个连接一个线程,与并发式服务器有点类似,只不过多了一个reactor。
- 不好
7.reactor+worker thread pool(能适应密集计算)
-
muduo支持
-
5.reactor+thread per request(过渡方案)的改进
-
解释:
一个客户端发送了请求包,在reactor线程(也称之I/O线程)中读取请求包,把请求包丢到线程池中处理;
线程池取出一个工作线程来处理请求包:decode,compute,encode,即使compute工作量大,也不影响reactor线程,也可以接受其他客户端连接过来;
能够适用于计算密集型任务,执行时间较长的服务也能适应;
线程池中的线程并不负责数据的发送,要相应数据包还得丢到reactor线程中来send(或者异步调用IO线程的发送方法send来发送);
8.multiple reactors(能适应更大的突发I/O)
-
多个事件循环,多个reactor,每个reactor都是一个线程或者进程;只有一个线程来处理网络I/O,可能会出现瓶颈
-
模式
(1)reactor in threads
每个线程都有一个reactor事件循环EventLoop,muduo推荐
(2)reactor in processes
每个进程都有一个reactors -
解释
每一个reactor都是一个线程或者进程;
mainReactor将监听socket加入进去,每次当client连接过来,监听socket产生可读事件,accepor返回已连接socket;
将已连接socket加入到subReactor中,用它来处理client端的连接;
若再来一个client,按序的将其分配到下一个subReactor;
若只有2个subReactor,若再来一个client,就将其分配到第一个subReactor,这种调度方式,称之为round robin;
它能保证subReactor所处理的连接相对来说比较均匀,每一个连接只能在一个reactor线程中处理,在该线程read,在该线程中send;
当使用一个reactor时,可能该线程会产生瓶颈,使用多个reactor,就能处理突发的I/O请求;
一般来说,一个reactor能够适应一个千兆网口,若服务器有3个千兆网口就分配3个subreactor,再加上mainReactor就是4个;
由于subReactor也是线程,所以可以将其看成是I/O线程池,这些I/O线程池的线程个数一般是固定的,一开始就设定好了,根据千兆网口的数目设置线程数;
若计算compute的请求量过大,还可以这块内容放到线程中计算,这就叫做:multiple reactors+thread pool
9.multiple reactors+thread pool(one loop per thread+threadpool)(突发I/O与密集计算)
- multiple reactors必须用线程实现,因为进程没办法共享thread pool
- 完美模型,muduo支持
- 解释:
subReactor可以有多个;
每个subReactor将与计算相关的操作丢到thread pool中处理,处理完毕后,应答包交给subReactor来应答;
若有多个acceptor,则他们共享一个thread pool;
相当于有2个线程池:I/O线程的线程池reactor+计算线程的线程池
10.proactor服务器(proactor模式,基于异步I/O)
-
前面所接收的是基于同步IO
-
解释
若是回调函数,则该回调函数则是在一个线程中执行的;
我们发现通过异步IO,IO操作和其他处理就能够重叠(I/O与other processing操作可以重叠),就是系统IO操作的同时,应用程序的其他操作也在执行,这个充分利用了硬件的DMA特性,直接存储访问,而不需要CPU的干预;
-
对比
非阻塞的异步IO与同步IO区别:
(1)非阻塞同步IO仅仅是给fd增加了O_NONBLOCK属性,然后调用read来接收,若数据没有准备好,则立刻返回,是不阻塞的,这仍然是同步IO,若返回的是EAGAIN或者EWOULDBLOCK,则还需再调用read接收数据,若使用非阻塞的I/O,则会处于忙等状态;
(2)与I/O复用的区别:
I/O复用仅仅是得到通知,内核中有数据了,你可以读取了,得到通知后,还需要调用read接收数据,得到通知,I/O是并没有完成的,数据仍在内核缓冲区中,并没有拷贝到用户空间的缓冲区,需要调用read将数据从内核缓冲区拉到应用层缓冲区中。
(3)异步IO
一旦I/O完成,内核直接把数据主动推到应用层的缓冲区。 -
aio总结
(1)理论上proactor比reactor效率要高一些
(2)异步I/O能够让I/O操作与计算重叠。充分利用硬件的DMA特性。
(3)Linux异步I/O
(4)boost库的asio库中实现的proactor,不是真正的异步IO,而是底层使用epoll来模拟实现的异步IO
(5)也就是说linux下的异步IO并不完美;
实际上现实生活中的linux下的网络编程,更多的是使用reactor模式,上面的第9个模式就是最好的模式;
windows下的异步IO可以使用完成端口;
Linux下主要有两种异步IO,
(1)一种是由glibc实现的aio开头的一系列函数、有bug(一般不使用)
(2)kernel native aio(io_*), 也不完美。目前仅支持O_DIRECT方式来对磁盘读写,跳过系统缓存。要自己实现缓存,难度不小。
man aio_read,man 7 aio
aio_read需要指定一块缓冲区,由struct aiocb 结构体来指定
int aio_read(struct aiocb *aiocbp);
struct aiocb
{
int aio_fildes; /* 要被读写的fd */
void * aio_buf; /* 读写操作对应的内存buffer */缓冲区
__off64_t aio_offset; /* 读写操作对应的文件偏移 */
size_t aio_nbytes; /* 需要读写的字节长度 */
int aio_reqprio; /* 请求的优先级 */
struct sigevent aio_sigevent; /* 异步事件,定义异步操作完成时的通知信号或回调函数 */
//事件通知方式,回调函数需要在线程中执行
};
- 常见并发服务器方案总结
11.几个问题
-
Linux能同时启动多少个线程
对于 32-bit Linux,一个进程的地址空间是 4G,其中用户态能访问 3G 左右,而一个线程的默认栈 (stack) 大小是 10M,心算可知,一个进程大约最多能同时启动 300 个线程左右 -
多线程能提高并发度吗 ?
(1)如果指的是“并发连接数”,不能。
(2)假如单纯采用 thread per connection 的模型,那么并发连接数大约300,这远远低于基于事件的单线程程序所能轻松达到的并发连接数(几千上万,甚至几万)。所谓“基于事件”,指的是用 IO multiplexing event loop 的编程模型,又称 Reactor 模式。 -
多线程能提高 吞吐量吗?
(1)对于计算密集型服务,不能(单位时间的吞吐量是无法提高的)。
(2)如果要在一个8核的机器上压缩100个1G的文本文件,每个core的处理能力为200MB/s,那么“每次起8个进程,一个进程压缩一个文件”与“只启动一个进程(8个线程并发压缩一个文件)”,这两种方式总耗时相当,但是第二种方式能较快的拿到第一个压缩完的文件 -
多线程 如何让 I/O 和计算重叠
(1)多线程程序如何让I/O和计算重叠,降低latency(迟延)
(2)例:日志(logging),多个线程写日志,由于文件操作比较慢,服务线程会等在IO上,让CPU空闲,增加响应时间。
(3)解决办法:单独用一个logging线程负责写磁盘文件,通过BlockingQueue提供对外接口,别的线程要写日志的时候往队列一塞就行,这样服务线程的计算和logging线程的磁盘IO就可以重叠。
如果异步I/O成熟的话,可以用protator模式。
(4)protator模式还可以让同一个线程的计算和IO重叠,重复利用DMA特性,执行IO的时候交给DMA,不交给CPU -
线程池大小的选择
(1)如果池中执行任务时,密集计算所占时间比重为P(0<P<=1),而系统一共有C个CPU,为了让C个CPU跑满而不过载,线程池大小的经验公式T=C/P,即T*P=C(让CPU刚好跑满 (因为只有计算工作耗费CPU,I/O操作可以让DMA去操作,I/O操作不耗费CPU了))
假设C=8,P=1.0,线程池的任务完全密集计算,只要8个活动线程就能让CPU饱和
假设C=8,P=0.5,线程池的任务有一半是计算,一半是IO,那么T=16,也就是16个“50%繁忙的线程”能让8个CPU忙个不停。
(2)当P<0.2时,需要经过测试得到一个固定的线程数; -
线程分类
(1)I/O线程(这里特指网络I/O),即reactor模式,每一个reactor模式就是一个I/O线程
(2)计算线程 这个比较耗费CPU
(3)第三方库所用线程,如logging,又比如database,这些线程可以单独起来,不要与计算线程混在一起