IO网络模型的应用-基于线程实现&基于事件驱动实现

IO模型两个体系结构介绍

网络IO有很多种实现方式,主要的有两个体系结构,分别是:基于线程实现的设计,与基于事件驱动的设计。

一、基于线程实现的设计

基于线程实现遵循的思想是一个线程来处理一个连接(One-Connection-Per-Thread)。适用于:使用了非线程安全库而又要避免线程竞争的站点。

1.  iterative 服务器

这是最原始的网络思路,程序只有一个主进程。

主进程是一个死循环,不断的accept端口。当有连接创建后,就开始执行业务逻辑。处理完成后,关闭socket,循环这一过程。

这个方案也不适合长连接,但是很适合daytime这种write-only服务。iterative服务器一次只能处理一个调用,这样服务没有办法同时为多个客户端服务。

2.  预派生子进程,主 进程调用 accept

为了可以同时服务多个客户端,产生了称之为process-per-connection的方式。当创建连接后,fork一个子进程用来处理这个请求直到客户端断开连接。同时主进程则立即再次accept监听新的请求。

这使得服务器能同时为多个客户端服务。每个子进程服务于一个客户端的长连接,处理多个任务。客户端数目的唯一限制是操作系统对用户能拥有多少子进程的限制。该方案适合并发连接数不大且一个连接上有很多有顺序的任务,同时计算响应时间的工作量远大于fork( )的开销的情况。

本方案中,如果使用子线程代替子进程的方案叫thread-per-connection。在 Java 1.4引入NIO之前,Java网络服务程序多采用thread-per-connection。线程方案中伸缩性受到操作系统线程数的限制,操作系统的scheduler一两百个还行,几千个的话是个不小的负担。

注:当主进程accept连接并fork子进程处理连接上的任务时,主进程close不会关闭连接。只有当主进程与子进程都close后,才会关闭连接。

3.  预派生子进程,每个子进程调用accept

如果请求以短连接为主,频繁的fork,开销过多。此时就可预先派生进程,多个子进程处理请求,以减少运行过程中fork的开销。

优点:

  • 初始化时创建多个进程处理客户端连接,减少运行过程中fork的开销。

缺点:

  • 预创建进程数目>客户端数目:造成进程资源浪费,增加进程切换开销;

  • 预创建进程数目<客户端数目:新到的客户端将得不到服务,内核仍会完成三步握手,造成服务器在响应时间上的恶化;

  • 惊群现象:服务进程在程序启动阶段派生N个子进程,各个子进程阻塞在对同一个listenfd的accept调用上,当第一个客户端连接到达时,所有阻塞的子进程都将被唤醒,其中只有最先运行的子进程将获得客户端连接。“惊群现象”造成性能受损。

4. 预派生子进程,锁保护accept

如图所示,让应用程序在调用accept前后安置某种形式的锁,这样在任意时刻只有一个子进程阻塞在accept调用中,不会产生多个进程同时调用系统accept,减少频繁的用户态与系统态的切换。

nginx以在1.11.3在“惊群现象”问题采用同样的方法解决。nginx多进程的锁在底层默认是通过CPU自旋锁来实现。如果操作系统不支持自旋锁,就采用文件锁。

linux 4.5支持了EPOLLEXCLUSIVE,nginx-1.11.3也支持EPOLLEXCLUSIVE,不在自己在accept上实现锁了,交由系统底层实现。

5.  预先派生子进程,父进程accept

惊群现象”另一个方案:预先创建子进程,父进程负责accept监听端口,然后把连接上接收的数据通过套接字传递给子进程,以解决所有子进程的accept调用上锁问题。

W.Richard Stevens通过实验指出:父进程通过字节流管道传递到各个子进程,并且各个子进程通过字节流写回管道,比使用上锁的方式要更费时。增加了数据传输拷贝开销。实现上也更复杂,不推荐使用。

基于进程或线程实现的设计瓶颈

线程:是共享地址空间,从而可以高效地共享数据。

进程:一台机器上的多个进程能高效地共享代码段(操作系统可以映射为同样的物理内存),但不能共享数据。

因此线程模型要比对应的进程模型要快。但线程运行在同一地址空间,一个线程的崩溃将导致整个进程的崩溃,另外无法实现热升级,nginx就是采用进程模型,支持reload\upgrade。

进程与线程都受到操作系统线程数的限制。操作系统的scheduler一两百个进程还行,1千以内的线程还行,但是系统scheduler变得无法承受负担。

连接与线程之间存在对应关系,一个连接从开始到关闭一直会占用一个线程,如果使用Keep-Alive这样减少连接的创建成本的方式,势必会导致大量的工作线程在空闲状态下等待。另外,数百甚至数千并发连接所创建的线程会浪费存储器中的大量堆栈空间。

二、基于事件驱动的设计

基于事件驱动设计遵循的思想是将线程与连接分离开,线程只是用来处理特定的回调或业务逻辑。

1.  Reactor 

Reactor设计模式是基于事件驱动的一种实现方式,采用基于事件驱动的设计,当有事件触发时,才会调用处理器进行数据处理。

主进程acceptor监听端口,一次获取多个连接,顺序处理每个连接的业务逻辑。虽然一次可以处理多个请求,但实现上还是一个线程完成所有任务。不适合多核CPU。

2. Reactor+Thread-per-task

在Reactor的基础上,acceptor读取到多个连接的多个任务后,为每个任务创建一个线程去处理,然后在处理完成时消毁线程。充分利用cpu,但增加了运行时动态创建线程的开销。另外多线程执行顺序不确定,会导致一个连接的多个请求,在多个线程同时处理:新来的还在执行中,后到的请求已经被执行完了。

3. Reactor+Threadpoll

为减少创建线程的开销,程序在启动时,预先创建线程池。Reactor使用acceptor接到请求事件后,读取请求数据,然后再将数据通过threadPool分配处理。利用线程池高效的处理CPU密集型的业务逻辑。

但如果服务是高IO型,只有一个线程负责读取没有办法充分利用多核。

4. Multiple Reactors

主线程负责acceptor监听端口,获取到多个连接后,将他们分配到多个subReactor。subReactor负责数据的读取&处理,提高的了IO的吞吐量。

subReactor的数据一般是固定的,为CPU核数或CPU核数的2倍。程序可以充多利用多核,当IO的并发升高后,依赖多核能力,总体处理能力不会下降。

一个连接的业务逻辑一直在一个线程中处理,可以保证数据的处理是有序的。但是由于subReactor处理是一个线程,如一个业务逻辑耗费大量CPU时间时,业务处理能力就开始下降。

5. Multiple Reactors+Threadpoll

为了兼顾 IO密集型与CPU密集弄的问题。使用多个subReactor+ 处理线程池的方式。

总结

我们使用的nginx、apache、tomcat、netty、muduo、javaNIO之类的软件,他们都是使用上面设计思想中一种或几种的组合。

我们在应用的时候,需要按我们的具体需要场景来使用,比如:是长连接任务多还是短连接多、一个连接上任务的有序性要求、事务性、IO密集型、CPU密集型等来选定服务实现方式。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章