并发模型和 I/O 模型介绍
并发模型
常见的并发模型一般包括 3 类,基于线程与锁的内存共享模型,actor 模型和 CSP 模型,其中尤以线程与锁的共享内存模型最为常见。由于 go 语言的兴起,CSP 模型也越来越受关注。基于锁的共享内存模型与后两者的主要区别在于,到底是通过共享内存来通信,还是通过通信来实现访问共享内存。由于 actor 模型和 CSP 模型,本人并不是特别了解,我主要说说最基本的并发模型,基于线程与锁的内存共享模型。
为什么要并发,本质都是为了充分利用多核 CPU 资源,提高性能。但并发又不能乱,为了保证正确性,需要通过共享内存来协调并发,确保程序正确运转。无论是多进程并发,还是多线程并发,要么通过线程间互斥同步(spinlock,rwlock,mutex,condition,信号量),要么通过进程间通信(共享内存,管道,信号量,套接字),本质都是为了协同。多线程和多进程本质类似,尤其是 linux 环境下的 pthread 库,本质是用轻量级进程实现线程。下面以网络服务为例,简单讨论下多线程模型的演进。
最简单的模型是单进程单线程模型,来一个请求处理一个请求,这样效率很低,也无法充分利用系统资源。那么可以简单的引入多线程,其中抽出一个线程监听,每来一个请求就创建一个工作线程服务,多个请求多个线程,这就是多线程并发模型。这种模式下,资源利用率是上去了,但是却有很多浪费,线程数与请求数成正比,意味着频繁的创建/销毁线程开销,频繁的上下文切换开销,这些都是通过系统调用完成,需要应用态到内核态的切换,导致 sys-cpu 偏高,资源并没有充分利用在处理请求上。
为了缓解这个问题,引入线程池模型,简单来说,就是预先创建好一批线程,并且加大线程的复用能力,将线程数控制在一定数目内,缓解上下文切换开销。以 MySQL 线程池为例,原来多线程模型是单连接单线程,现在变成单语句单线程,提高了线程复用效率。如果线程在执行过程中遇到等待(锁等待,IO 等待),那么线程挂起,并减少活跃线程数,告知线程池系统活跃线程可能不够,需要追加线程,然后等系统空闲时,再减少线程数目,做到根据系统负载平衡线程数目。为了做到极致,更进一步减少上下文切换开销,引入了协程,协程只是一种用户态的轻量线程,它运行在用户空间,不受系统调度。它有自己的调度算法。在上下文切换的时候,协程在用户空间切换,而不是陷入内核做线程的切换,减少了开销。协程的并发,是单线程内控制权的轮转,相比抢占式调度,协程是主动让权,实现协作。协程的优势在于,相比回调的方式,写的异步代码可读性更强。缺点在于,因为是用户级线程,利用不了多核机器的并发执行。简单总结下:
I/O 模型
linux 中所有物理设备对于系统而言都可以抽象成文件,包括网卡,对应的就是套接字,磁盘对应的文件,以及管道等。因此所有对物理设备的读写操作都可以抽象为 IO 操作,典型的 IO 操作模型分为以下几类,阻塞 IO,非阻塞 IO,I/O 多路复用,异步非阻塞 IO 以及异步 IO 等。
【文章福利】另外小编还整理了一些 C/C++后台开发教学视频,相关面试题,后台学习路线图免费分享,需要的可以自行添加:Q群:720209036 点击加入~ 群文件共享
小编强力推荐 C++后台开发免费学习地址:C/C++Linux服务器开发高级架构师/C++后台开发架构师
IO 模型分类
阻塞 I/O--> 原生的 read/write 系统调用,默认导致线程阻塞;
非阻塞 I/O -->通过指定系统调用 read/write 的参数为非阻塞,告知内核 fd 没就绪时,不阻塞线程,而是返回一个错误码,应用死循环轮询,直到 fd 就绪;
I/O 多路复用-->(select/poll/epoll),对通知事件堵塞,对于 I/O 调用不堵塞。
异步 I/O(异步非阻塞)-->告知内核某个操作(读写 I/O),并让内核在整个操作(包括将数据复制到我们的进程缓冲区)完成后通知。
I/O 多路复用
常见的 I/O 多路复用主要用于网络 IO 场景,主要有 select,poll 和 epoll 机制。对比同步 I/O,实际上是对 I/O 请求加了一层代理,由这些代理去监听通知事件(是否网络包到来),然后再通知用户去读写数据。这种方式也是一种阻塞 I/O,代理对通知事件阻塞,这里的代理一般指监听线程。对比 select,poll 提升了最大支持文件描述符数目,从 1024 提升到 65535,MySQL 中的半同步复制还因为使用 select 的这个限制,导致半同步中断的 bug(链接)。
对比 select 和 poll 机制,epoll 通过事件表管理用户感兴趣的事件,无需反复传入用户感兴趣事件,处理事件通知的时间复杂度是 O(1),而 select,poll 机制的时间复杂度是 O(N)。另外 select/poll 只能工作在 LT 模式(水平触发模式);而 epoll 不仅支持 LT 模式,还支持 ET 模式(边缘触发模式)。两种模式的主要区别是,有数据可读时,LT 模式会不停的通知,直到数据被获取,这种模式不用担心通知事件丢失;ET 模式只会通知一次,因此对比 LT 少很多 epoll 系统调用,效率更高。epoll 对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。从本质上讲,与 LT 相比,ET 模型是通过减少系统调用来达到提高并行效率的。
libev/libeasy
epoll 很好用,但是要使用 epoll,fd,signal,timer 分别要采用不同的机制才能一起工作。libev 第一个要做的事情就是把系统资源统一成一种调用方式。因为都需要在读写事件就绪后自己负责进行读写,也就是读写过程是阻塞的。libev 的核心是事件处理框架,最常见的是就是一个所谓的 Reactor 事件处理框架和设计模式。Reactor 对象负责实现主循环(其中有事件分离器的调用),定义事件处理接口,用户程序向 Reactor 注册事件回调的实现类(从接口继承),Reactor 主循环在收到事件的时候调用相应的回调函数。libeasy 实现类似 libev 和 libevent 的功能,包括 HTTP 服务器等,不同的是,它基于 libev 做了包装,提供了同一个的资源 fd 和 loop 机制,线程池,异步框架等实现。
AIO
说到 AIO,一般是说磁盘的异步 I/O,linux 早期的版本并没有真正的 AIO 接口,所谓的 AIO 其实是多线程模拟的,在应用态完成。具体而言就是有一个队列存储 IO 请求,通过一组工作线程提取任务,并发起同步 IO,待 IO 完成后,再通知用户已经完成了。对于用户而言,由于是提交 IO 请求后就直接返回,然后再被通知 IO 已经完成,所以可以认为是异步 I/O,这种异步 I/O 实现机制主要指 POXIS AIO,MySQL 的 InnoDB 引擎也实现了一套类似的 AIO 机制。后面 linux 内核引入了真正的 AIO,主要区别在于发起 I/O 调用不再是同步调用,IO 请求统一在内核层面排队,并且一次可以提交一批异步 IO 请求,然后通过轮询或者回调的方式接收完成通知即可。相比于 POXIS AIO,底层有更多的 IO 并行,IO 和 CPU 能充分并发,大大提升性能。在使用中,通过-lrt 链接使用 AIO 库是 POXIS 接口,而通过-laio 链接使用的 AIO 库是 linux Native AIO 接口。常用接口包括 io_setup,io_destroy,io_submit,io_cacel 和 io_getevents 等。
对比
同步 IO:
优点:简单
缺点:IO 阻塞,无法充分利用 IO 和 CPU 资源,效率低
Native AIO:
优点:AIO 可以支持一次发送多个不连续的异步 IO 请求,性能更好(同步 IO 需要发送多次)
缺陷:需要文件系统支持 O_DIRECT 选项,如果不支持,io_submit 实际上是“退化”成同步操作。
POSIX AIO:
优点:不依赖 O_DIRECT 选项,有一定的合并能力(相邻地址的请求,可以做 merge)。
缺点:并发的 IO 请求受限于线程数目;另外就是,可能慢速磁盘,可能导致其它新的请求没有及时处理(工作线程数不够了)。
参考资料
推荐一个零声教育 C/C++后台开发的免费公开课程,个人觉得老师讲得不错,分享给大家:C/C++后台开发高级架构师,内容包括Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习
评论