目录
在面对异步IO频繁的业务需求的时,可以使用回调的机制。在利用回调的过程中,如果利用状态机则会发生回调金字塔(callback hell),主要表现为:1.代码复用率极低。2.逻辑复杂。此时利用协同程序可以很好的解决这个问题。
CPU (CentralProcessingUnit)
计算机的核心是CPU,所有的计算任务都是由它完成。CPU概念包括:
物理CPU
物理CPU就是插在主机上的真实的CPU硬件,在Linux下可以数不同的physical id 来确认主机的物理CPU个数。
核心数
物理CPU下一层概念就是核心数,我们常常会听说多核处理器,其中的核指的就是核心数。在Linux下可以通过cores来确认主机的物理CPU的核心数。
逻辑CPU
逻辑CPU跟超线程技术有联系,假如物理CPU不支持超线程的,那么逻辑CPU的数量等于核心数的数量;如果物理CPU支持超线程,那么逻辑CPU的数目是核心数数目的两倍。在Linux下可以通过 processors 的数目来确认逻辑CPU的数量。
层级如下图所示:
进程与线程
**进程是CPU资源分配的最小单位,线程是CPU调度的最小单位。**进程包含线程,常见的模型有:
1.单进程单线程模型
进程与线程的区别在于:进程掌管着资源,线程是进程的一部分,CPU执行调度的是线程。
2.单进程多线程模型
多个线程共享全局资源,但不包括线程自己的栈空间。
3.多进程单线程模型
单个应用可以通过fork拷贝出多个进程,进程的资源会进行复制。注意:fd同样会复制,不建议多个进程共用同一个fd,会出现数据乱序的情况。
4.多进程单线程模型
涉及多线程时,锁是个必须掌握的知识点,否则同时对共享资源进行处理可能导致覆盖写问题。
网络编程中5种I/O模型
在Unix(linux)平台下有5中I/O模型:
同步I/O模型
堵塞I/O模型(blocking I/O)
非堵塞I/O模型(un-blocking I/O)
I/O多路复用模型(select, poll, epoll)(较常见)
信号驱动I/O模型(SIGIO)
异步I/O模型
同步与异步的区别
同步:指关于这个I/O中的一系列动作都需要自己来完成,无论你是原地等待事件的发生(阻塞)还是当某个事件已经准备好的时候你去完成后面的的动作(非阻塞)都属于同步。
异步:它是指是调用另一个执行者去完成,当执行者发现要处理的事件后调用你,你再完成这件事情,执行的过程和你的动作是不牵扯的。
因此,前四种是同步I/O模型,只有第五种是异步的。
I/O操作一般分为两个阶段:
- 等待数据达到内核缓存区
- 将数据从内核拷贝到用户进程
阻塞型I/O
通常把阻塞的文件描述符(file descriptor,fd)称之为阻塞I/O。默认条件下,创建的socket fd是阻塞的,针对阻塞I/O调用系统接口,可能因为等待的事件没有到达而被系统挂起,直到等待的事件触发调用接口才返回,例如,tcp socket的recvfrom调用会阻塞至连接有数据返回,如上图所示。另外socket 的系统API ,如,accept、send、connect等都可能被阻塞。
非阻塞型I/O
一种轮询的机制
把非阻塞的文件描述符称为非阻塞I/O。可以通过设置SOCK_NONBLOCK标记创建非阻塞的socket fd,或者使用fcntl将fd设置为非阻塞。
对非阻塞fd调用系统接口时,不需要等待事件发生而立即返回,事件没有发生,接口返回-1,此时需要通过errno的值来区分是否出错,有过网络编程的经验的应该都了解这点。不同的接口,立即返回时的errno值不尽相同,如,recv、send、accept errno通常被设置为EAGIN 或者EWOULDBLOCK,connect 则为EINPRO- GRESS 。
I/O多路复用
最常用的I/O事件通知机制就是I/O复用(I/O multiplexing)。Linux 环境中使用select/poll/epoll 实现I/O复用,I/O复用接口本身是阻塞的,在应用程序中通过I/O复用接口向内核注册fd所关注的事件,当关注事件触发时,通过I/O复用接口的返回值通知到应用程序,如图3所示,以recv为例。I/O复用接口可以同时监听多个I/O事件以提高事件处理效率。
好处:其实这就是一个回调实现的机制。在这个过程中,只需要两个线程就可以完成多个连接请求。一个为业务线程,一个为epoll模型监听线程。这样的话,就可以利用一个业务线程进行大量的访问请求处理。而不必像PHP等实现机制,每一个请求都分配一个线程,之后阻塞等待。
回调机制
在这种典型的回调机制的实现过程中,通过epoll模型返回的结果即状态机的条件,以及结合上下文即状态机的现态,可以触发动作。即:条件+现态->动作(状态机是一种switch-case的结构,逻辑是非顺序的,类似于一种表格的结构—详见深入浅出理解有限状态机),因为状态机的逻辑非顺序化,所以将其逻辑顺序化的过程,会出现callback hell的问题。如下图所示:
解决这样的问题协程是一种有效的手段,即:协程可以处理大量的异步I/O操作的需求业务。
信号驱动I/O
除了I/O复用方式通知I/O事件,还可以通过SIGIO信号来通知I/O事件,如上图所示。两者不同的是,在等待数据达到期间,I/O复用是会阻塞应用程序,而SIGIO方式是不会阻塞应用程序的。
异步I/O
POSIX规范定义了一组异步操作I/O的接口,不用关心fd 是阻塞还是非阻塞,异步I/O是由内核接管应用层对fd的I/O操作。异步I/O向应用层通知I/O操作完成的事件,这与前面介绍的I/O 复用模型、SIGIO模型通知事件就绪的方式明显不同。以aio_read 实现异步读取IO数据为例,如图5所示,在等待I/O操作完成期间,不会阻塞应用程序。
目前常见的服务端模型(多进程结合I/O多路复用)
伪代码:
void dispatch(...){
将connect_fd加入epoll队列,等待可读(可写)事件
}
void run(...){
执行业务逻辑(read或者write该fd)
//其他业务逻辑(如curl)
}
int main()
{
创建listen_fd监听端口
fork创建子进程
if(当前进程为父进程){
/*************父进程***************/
管理子进程
}else{
/*************子进程***************/
子进程创建epoll队列
将dispatch事件绑定到listen_fd的可读事件上
使用epoll函数监听listen_fd
while(1){
//第一次触发epoll函数
出现listen_fd可读(可写)事件,触发dispatch事件
epoll队列中有listen_fd和每个客户端的connect_fd
//第N次(N!=1)
出现listen_fd或者connect_fd的可读(可写)事件,触发对应的dispatch事件或者run事件
}
}
}
从服务器端的代码逻辑可以知道,epoll是可以同时监听多个fd的,并且在有对应的事件时才唤醒对应的事件函数,这是通常说的异步调用。
但是,这里有个问题,如果在run函数中业务逻辑需要创建tcp连接(如curl)请求其他服务接口(创建fd),此时的fd因为没有使用epoll,就会出现阻塞。
那我们设想一下,如果在run函数中写epoll监听该fd,那如果这个tcp连接请求的后续事件依旧需要创建tcp请求呢,这个代码该怎么编写下去呢,这就是常说的一种情况,回调地狱。
那如何优雅解决这个问题呢?
设想一下,如果我们不需要去写epoll监听,直接对read/write函数进行hook,默认所有IO操作行为会被直接加入epoll队列,这样就不会有回调地狱。因此,协程诞生了!
协程
协程是一种程序组件,是由子例程(过程、函数、例程、方法、子程序)的概念泛化而来的,子例程只有一个入口点且只返回一次,而协程允许多个入口点,可以在指定位置挂起和恢复执行。协程是一种“伪多线程”,一个线程中可以包含多个协程,但同一时刻只能有一个协程在运行。
协程是一种可以暂停执行过程的函数,它可以中断当前的执行过程直到下一个Yield指令达成。在实现上,大多数都是以函数来作为一个协程,因此这里列出此简化的定义方便理解。
例如:实现一个0到9的循环输出。
int function(void) {
static int i, state = 0;
switch (state) {
case 0: /* start of function */
for (i = 0; i < 10; i++) {
state = __LINE__ + 2; /* so we will come back to "case __LINE__" */
return i;
case __LINE__:; /* resume control straight after the return */
}
}
}
用static变量保存上下文,再用switch进行代码行的跳转,就可以实现一个简单的协程。
协程的运用
总结
协程主要适合于一些IO比较频繁的系统,在这样的系统中,使用协程跟多线程的优缺点比较如下:
- 单线程异步IO: 优点是性能高,代码执行是顺序的,不需要关心锁,竞争等情况;缺点是需要自己处理异步I/O、epoll等,无法便捷地做到hook所有fd操作;
- 协程: 比单线程异步I/O容易编程,代码更好写,协程里面是顺序编程的,但协程之间是独立栈,共享堆内存,单线程执行环境,在一个CPU上运行。协程切换代价比线程少多了,只需要十几条汇编指令切换寄存器。每秒据说能达到上百万次切换。
- 多线程同步I/O: 代码相对也好写,跟协程一样独立栈,共享堆内存。 需要处理资源竞争问题,而且线程切换代价特别大,linux里面没有原生的线程,是用进程实现的。
- 多进程:代码相对容易编写,但需要解决共享数据的问题。