大并发服务器的多线程与并发服务器设计: 常见并发服务器方案

56 篇文章 1 订阅

1.循环式/迭代式服务器:
无法充分利用多核CPU,不适合执行时间较长的服务,即适用于短连接(这样可以处理多个客户端),如果是长连接则需要在read/write之间循环,那么只能服务一个客户端。
所以循环式服务器只能使用短连接,而不能使用长链接,否则无法处理多个客户端的请求,因为整个程序是一个单线程的应用程序,如果a的处理请求不能断开(使用长链接)
那么下一个客户端的请求将不能连接上。因为是单线程的也无法充分利用多核CPU。

2、并发式(concurrent)服务器
one connection per process/one connection per thread
适合执行时间比较长的服务

one connection per process

子进程在处理客户端的请求,父进程关闭客户端的套接字,等待下一个客户端请求连接,所以可以并发式(concurrent)服务器处理多个客户端的请求,一个客户端一个进程,而且,子进程是长连接的,不断的处理请求,即使子进程中解包,计算,打包的过程时间过长,也不会影响父进程去连接其他客户端的请求。

这有一个问题:父进程关闭,子进程会怎么办,会有影响吗?这个问题的答案可以参考https://blog.csdn.net/SweetTool/article/details/75059060

并发服务器也可以使用线程

one connection per thread : 主线程每次accept 回来就创建一个子线程服务,由于线程共享文件描述符,故不用关闭。最后一个线程关闭监听套接字。

3、prefork or pre threaded(UNP 第27章)(容易发生“惊群”现象,即多个子进程都处于accept状态)

预先创建进程或者预先创建线程

一个进程提前fork了若干个子进程,每个子进程负责和客户端的通信,每个子进程执行的也是上图右边的步骤。前面的创建套接字,绑定端口号,监听套接字这些步骤,每个预先创建的线程已经完成,他们分别调用accept并由内核置入睡眠状态。

这种服务器的优点是:不需要引入父进程执行fork的开销,新客户就能得到处理。提高了响应速度,预先创建线程也是这个优点。

缺点在于:每次启动服务器,父进程必须猜测到底需要预先派生多少子进程,还有一个就是如果不考虑再派生子进程,先前派生的子进程可能被客户请求占用完,以后新到的请求只能先完成三次握手,并且达到listen接口的最大并发连接数backlog, 直到有子进程可用。服务器才调用accept,将这些已经完成的连接传递给accept。

还有一个是惊群问题:当一个客户端连接过来的时候,由于多个子进程都处于accept状态,这种服务器由于多个进程在accept等待中,当一个请求到达时,都会被触发,都会返回,但是只有一个成功返回。这是一种“惊群”现象,有多个进程同时等待网络的连接事件,当这个事件发生时,这些进程被同时唤醒,就是“惊群”,惊了一群。唤起多余的进程间影响服务器的性能(仅有一个服务进程accept成功,其他进程被唤起后没抢到“连接”而再次进入休眠)。这样会影响性能

如何解决惊群现象

首先说明加锁并不能解决惊群问题,因为解锁以后,还是有很多等待accept,那么有效的解决惊群问题的方法是什么?在unix网络编程一书中的27.9节提到,就是由父进程调用accept,而子进程不调用accept,然后由父进程将已经连接好的描述符套接字传递给子进程,这样就绕过了 处于accept状态的子进程全都被唤醒,这种技术比较复杂,父进程必须跟踪子进程的忙闲状态,以便给空闲子进程传递描述符套接字。

我这儿说的不够详细,关于惊群问题的详细介绍详见https://blog.csdn.net/lyztyycode/article/details/78648798

介绍下面之前先要知道reactor是一个时间循环机制。

4、反应式( reactive )服务器 (reactor模式)(select/poll/epoll)

并发处理多个请求,实际上是在一个线程中完成。也就是单线程轮询多个客户端无法充分利用多核CPU
不适合执行时间比较长的服务,所以为了让客户感觉是在“并发”处理而不是“循环”处理,每个请求必须在相对较短时间内执行。当然如果这个请求不能在有限的时间内完成我们可以将这个请求拆分开来,使用有限状态机机制来完成。不过因为有比这种服务器更好的方案,现实生活中即使可以使用有限状态机,也不使用这种服务器。

select/poll/epoll就可以实现反应式服务器。

①  服务器端的Reactor是一个线程对象,该线程会启动事件循环,并使用Selector(里面可以是select/poll/epoll)来实现IO的多路复用。注册一个Acceptor事件处理器到Reactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样Reactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。

② 客户端向服务器端发起一个连接请求,Reactor监听到了该ACCEPT事件的发生并将该ACCEPT事件派发给相应的Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的READ事件以及对应的READ事件处理器注册到Reactor,中,这样一来Reactor就会监听该连接的READ事件了。或者当你需要向客户端发送数据时,就向Reactor注册该连接的WRITE事件和其处理器。

③ 当Reactor监听到有读或者写事件发生时,将调用dispatch将相关的事件分发给对应的处理器进行处理。比如,读处理器会通过SocketChannel的read()方法读取数据,此时read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。

④ 每当处理完所有就绪的感兴趣的I/O事件后,Reactor线程会再次执行select()阻塞等待新的事件就绪并将其分派给对应处理器进行处理。

注意,Reactor的单线程模式的单线程主要是针对于I/O操作而言,也就是所以的I/O的accept()、read()、write()以及connect()操作都在一个线程上完成的。

但在目前的单线程Reactor模式中,不仅I/O操作在该Reactor线程上,连非I/O的业务操作也在该线程上进行处理了,这可能会大大延迟I/O请求的响应。所以我们应该将非I/O的业务逻辑操作从Reactor线程上卸载,以此来加速Reactor线程对I/O请求的响应。

这种的服务器的并发量比并发式服务器多,因为并发式服务器能够创建的进程或者线程数目是有限的

5.reactor+thread per request

每个请求过来,创建一个线程。这样就能利用多核CPU,这种是一个请求,一个线程

6.reactor+worker thread(事件循环机制加上一个工作的线程)

每一个连接在一个工作的线程中完成,也能利用多核CPU,这种模式是一个连接一个线程,与并发式服务器有点类似,只不过多了一个reactor。

7.reactor+worker thread pool(计算密集型)

与单线程Reactor模式不同的是,添加了一个时刻工作的线程池,并将非I/O操作从Reactor线程中移出转交给工作的线程池来执行。这样能够提高Reactor线程的I/O响应,不至于因为一些耗时的业务逻辑而延迟对后面I/O请求的处理。也就是说将解包,计算,打包等非IO操作交给线程池处理。

使用线程池的优势:
① 一个是线程池本身的好处:通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程产生的巨大开销。
② 另一个额外的好处是将IO操作与非IO操作分离,当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。
③ 这个也是线程池本身的好处:通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态。同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。

注意,在上图的改进的版本中,所以的I/O操作依旧由一个Reactor来完成,包括I/O的accept()、read()、write()以及connect()操作。
对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发或大数据量的应用场景却不合适,主要原因如下:
① 一个NIO线程(指的同步非阻塞线程)同时处理成百上千的链路,性能上无法支撑,即便NIO线程的CPU负荷达到100%,也无法满足海量消息的读取和发送;
② 当NIO线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了NIO线程的负载,最终会导致大量消息积压和处理超时,成为系统的性能瓶颈;

8.multiple reactors +线程池(多reactor线程模型) 也即是说相当于给reactor也弄了线程池,注意为了后面的线程池可以被共享,前面的多reactor只能使用线程。这种模型也是muduo库推荐的模型。能够适用IO频繁且计算密集的服务。

reactor in threads(one loop per thread ,每个线程都有一个reactor(事件循环 event loop))

 

Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的事件循环逻辑。
mainReactor可以只有一个,但subReactor一般会有多个。mainReactor线程主要负责接收客户端的连接请求,然后将接收到的SocketChannel传递给subReactor,由subReactor来完成和客户端的通信。

流程:

① 注册一个Acceptor事件处理器到mainReactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样mainReactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。启动mainReactor的事件循环。

② 客户端向服务器端发起一个连接请求,mainReactor监听到了该ACCEPT事件并将该ACCEPT事件派发给Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将这个SocketChannel传递给subReactor线程池。

③ subReactor线程池分配一个subReactor线程给这个SocketChannel,即,将SocketChannel关注的READ事件以及对应的READ事件处理器注册到subReactor线程中。当然你也注册WRITE事件以及WRITE事件处理器到subReactor线程中以完成I/O写操作。Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的循环逻辑。

④ 当有I/O事件就绪时,相关的subReactor就将事件派发给响应的处理器处理。注意,这里subReactor线程只负责完成I/O的read()操作,在读取到数据后将业务逻辑的处理放入到线程池中完成,若完成业务逻辑后需要返回数据给客户端,则相关的I/O的write操作还是会被提交回subReactor线程来完成。

多Reactor线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个Reactor线程来完成。mainReactor完成接收客户端连接请求的操作,它不负责与客户端的通信(即连接后的对话),而是将建立好的连接转交给subReactor线程来完成与客户端的通信,这样一来就不会因为read()数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多Reactor线程模式在海量的客户端并发请求的情况下,还可以通过实现subReactor线程池来将海量的连接分发给多个subReactor线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。

10、proactor服务器(proactor模式,基于异步I/O) 而前面所接收的是基于同步IO

我们发现通过异步IO,IO操作和其他处理就能够重叠,就是系统IO操作的同时,应用程序的其他操作也在执行,这个充分利用了硬件的DMA特性,直接存储访问,而不需要CPU的干预,((Direct Memory Access,直接内存存取) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载。否则,CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU 对于其他的工作来说就无法使用。)

我们可以看到这个异步IO操作也是非阻塞的(系统IO在执行操作的同时,应用程序的I立即返回,并没有阻塞等待系IO的完成)

与IO复用有点像,只不过IO复用得到通知的时候,系统的IO操作并未完成,而是系统的内核缓冲区中有数据了,内核缓冲区中的数据并未复制到用户缓冲区,用户进程还需要使用read进行读取,

通过上面的分析得到:

理论上proactor比reactor效率要高一些

异步I/O能够让I/O操作与计算重叠。充分利用硬件的DMA特性。

inux下主要有两种异步IO,

一种是由glibc实现的aio开头的一系列函数、有bug。

glibc版本主要包含如下接口:
int aio_read(struct aiocb *aiocbp);  /* 提交一个异步读 */
int aio_write(struct aiocb *aiocbp); /* 提交一个异步写 */
int aio_cancel(int fildes, struct aiocb *aiocbp); /* 取消一个异步请求(或基于一个fd的所有异步请求,aiocbp==NULL) */
int aio_error(const struct aiocb *aiocbp);        /* 查看一个异步请求的状态(进行中EINPROGRESS?还是已经结束或出错?) */
ssize_t aio_return(struct aiocb *aiocbp);         /* 查看一个异步请求的返回值(跟同步读写定义的一样) */
int aio_suspend(const struct aiocb * const list[], int nent, const struct timespec *timeout); /* 阻塞等待请求完成 */

其中,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;      /* 异步事件,定义异步操作完成时的通知信号或回调函数 */

一种是由linux内核实现的,也不完美。目前只支持磁盘操作。

boost库的asio库中实现的proactor,不是真正的异步IO,而是底层使用epoll来模拟实现的异步IO。

也就是说linux下的异步IO并不完美,

实际上现实生活中的linux下的网络编程,更多的是使用reactor模式,上面的第8个模式就是最好的模式

windows下的异步IO可以使用完成端口(这个不太了解)。

网上还有一张图:常见并发服务器方案比较,我就不贴出来了。

 

 

 

 

 

 

 

 

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值