首先考虑一个很简单的问题,
假设一段代码a和b,a是cpu密集型运算,b是io密集运算。
a的运行时间是O(a)
b的运行时间是O(b)
如果用同步代码写的话,a+b的运行时间是 O(a+b),
用协程写,launch{a+b}的时间C,C < O(a+b) 吗?
这个问题的答案是,C确实会小于O(a+b)。
很多开发者,包括很多技术大V对协程的解释比较浅,认为没这种好事,C肯定还是等于O(a+b)的。
这个结论跟常识其实有点相反。一开始学习相关的技术点会觉得违反常理,但随着学习的深入,会发现这东西本来就应该是这样。
理解这个问题需要明白两个东西,
· Linux内核中断
· IO总线机制
下面的内容会尽量避免用专业技术知识,用最简单的逻辑解释为啥。
Linux内核中断
学习过计算机原理,或者学过单片机的应该听说过中断。
在Linux中也有中断,只是复杂了很多。通常来说中断可以简单分为硬件中断和软件中断。硬件中断是CPU外围硬件发起的,CPU通过引脚收到中断后,需要调用中断处理程序去处理中断。
软件中断则是由软件发起内核调用调起的中断。CPU同样会在收到中断后调用预先放在内存中的中断处理程序去处理这个中断请求。
举个例子说明中断是干嘛用的。我们平时对磁盘进行读写,其实都是通过中断来完成。比如在Linux系统里想读一个文件,或者说在android里,这个流程可以简化成这样。
app:hi 系统,我想读一个磁盘文件
操作系统:收到。hi CPU,你先停一下手上的活,这里有个读文件的事情给你
CPU:收到。(放下手上的活)hi IO总线,跟磁盘说一下,去读那个扇区上的文件
磁盘:loading…
磁盘:找到文件了,把文件内容写入到缓冲区…
磁盘:CPU你停一下,文件读完了
CPU:收到。hi 操作系统,跟app说一下可以读数据了
…
app:收到。正在从缓冲区读数据…
上面的流程跟大部分开发的常识是不一样的。一般我们说读文件,就是一行代码,
File f = new File(...)
但实际上在这行代码背后,是CPU-操作系统-总线互相配合完成了一系列的操作。
现在回到上面的例子,你会发现一个很有趣的地方,
在cpu请求IO去读文件的时候,cpu其实是没干活的
这也是为什么我们说IO是一个阻塞操作。因为这时候,当前进程或者说线程,占用了CPU并且啥事没做干等着。
问题来了,既然CPU的控制权还在手上,并且处于空转状态,是不是意味着可以把控制权交出去给其他任务进行计算?
协程的核心思想就是基于这么个理论。所以协程的挂起时机都是在suspend函数中。golang做的更极致,在编译过程中对于会发生挂起的地方,都直接帮开发者在编译过程封装成goroutine。
IO总线机制
总线包含了很多复杂的知识,为了说明中断和IO的关系,这篇文章里的描述很多地方都做了简化。
在计算机中,CPU和硬盘在一块主板上,硬盘通过数据线连接到主板。这条数据线也叫SATA总线。
串行ATA(英语:Serial ATA,全称:Serial Advanced Technology Attachment)是一种电脑总线,负责主板和大容量存储设备(如硬盘及光盘驱动器)之间的数据传输,主要用于个人电脑
在很久以前,计算机读取存储设备里的数据是非常慢的。很多人可能没见过3.5寸盘,上古时期还没有硬盘的时候,数据都存储在一块3.5寸盘里,再往前一点还有5寸盘。
在操作系统中读一块磁盘的数据,需要通过磁盘驱动器或者光驱去读。CPU首先要调用驱动器的驱动程序,通过SATA总线
,让驱动器扫描磁盘,定位到扇区,再定位到文件位置,最后才能读数据。这个过程还涉及到Linux内核态和用户态的切换,这里省略不讲。
现在的固态硬盘读个文件可能非常快,而在几十年前读磁盘实际是个非常开销性能的阻塞操作。磁盘技术经过几十年的发展,现在磁盘的读写速度已经非常快了,但这个交互逻辑延续了下来。其实不止磁盘IO,网络IO,也是一样的逻辑。
不管是在什么系统里,安卓也好linux也好,每个IO流程都一样。首先需要操作系统发起中断,CPU调用中断处理程序,通过总线通知驱动器去读磁盘。当CPU完成这个事情之后它就处于一个等待状态。
对于CPU和IO总线的交互方式,开发者就提出了 select 模型。select 的原理是由操作系统去轮训文件操作符(Linux系统里的概念),直到这个文件操作符有数据了,再把数据返回给应用层。
select操作在现在很多框架里也还有身影,比如Redis的源码中,就提供了select的默认实现。Redis除了select之外,还提供了另外一个叫 epoll 的操作用来替代select。
epoll是什么?
思考select的过程就会发现,在缓冲区的写事件完成之前,CPU在不停地轮询。轮询可以每10ms,每50ms,或者每100ms判断缓冲区是否写完成。不管时间间隔多少,这个过程其实都是对CPU算力的一个浪费。所以就有人提出一个新的思路,CPU通过总线通知驱动器读之后,就不管它了。等驱动器写完缓冲区,在由总线发起中断,CPU得到中断后,再去缓冲区读数据。
这里就是协程可以发挥作用的地方。假设在一个线程里发起读磁盘请求,那么在CPU发起请求到等待缓冲区的这个过程中,它的算力并没有被利用起来。一个很自然的想法是,这个时候能不能先把CPU的控制权交出来,给其他计算逻辑,等到收到中断了再回来去读缓冲区数据?
最开始实现这个技术的是微软。他们提供了一种叫Fiber的技术,在CPU空闲的时候可以把控制权交出去给其他Fiber运行。
Fiber也叫纤程,大部分开发可能都没听过这个概念。它现在仍然在微软的官网上,有兴趣可以看下。
上面的链接里有纤程的demo,摘取部分如下,
int __cdecl _tmain(int argc, TCHAR *argv[])
{
LPFIBERDATASTRUCT fs;
if (argc != 3)
{
printf("Usage: %s <SourceFile> <DestinationFile>\n", argv[0]);
return RTN_USAGE;
}
//
// Allocate storage for our fiber data structures
//
fs = (LPFIBERDATASTRUCT) HeapAlloc(
GetProcessHeap(), 0,
sizeof(FIBERDATASTRUCT) * FIBER_COUNT);
if (fs == NULL)
{
printf("HeapAlloc error (%d)\n", GetLastError());
return RTN_ERROR;
}
//
// Allocate storage for the read/write buffer
//
g_lpBuffer = (LPBYTE)HeapAlloc(GetProcessHeap(), 0, BUFFER_SIZE);
if (g_lpBuffer == NULL)
{
printf("HeapAlloc error (%d)\n", GetLastError());
return RTN_ERROR;
}
//
// Open the source file
//
fs[READ_FIBER].hFile = CreateFile(
argv[1],
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_FLAG_SEQUENTIAL_SCAN,
NULL
);
if (fs[READ_FIBER].hFile == INVALID_HANDLE_VALUE)
{
printf("CreateFile error (%d)\n", GetLastError());
return RTN_ERROR;
}
//
// Open the destination file
//
fs[WRITE_FIBER].hFile = CreateFile(
argv[2],
GENERIC_WRITE,
0,
NULL,
CREATE_NEW,
FILE_FLAG_SEQUENTIAL_SCAN,
NULL
);
if (fs[WRITE_FIBER].hFile == INVALID_HANDLE_VALUE)
{
printf("CreateFile error (%d)\n", GetLastError());
return RTN_ERROR;
}
//
// Convert thread to a fiber, to allow scheduling other fibers
//
g_lpFiber[PRIMARY_FIBER]=ConvertThreadToFiber(&fs[PRIMARY_FIBER]);
if (g_lpFiber[PRIMARY_FIBER] == NULL)
{
printf("ConvertThreadToFiber error (%d)\n", GetLastError());
return RTN_ERROR;
}
//
// Initialize the primary fiber data structure. We don't use
// the primary fiber data structure for anything in this sample.
//
fs[PRIMARY_FIBER].dwParameter = 0;
fs[PRIMARY_FIBER].dwFiberResultCode = 0;
fs[PRIMARY_FIBER].hFile = INVALID_HANDLE_VALUE;
//
// Create the Read fiber
//
g_lpFiber[READ_FIBER]=CreateFiber(0,ReadFiberFunc,&fs[READ_FIBER]);
if (g_lpFiber[READ_FIBER] == NULL)
{
printf("CreateFiber error (%d)\n", GetLastError());
return RTN_ERROR;
}
fs[READ_FIBER].dwParameter = 0x12345678;
//
// Create the Write fiber
//
g_lpFiber[WRITE_FIBER]=CreateFiber(0,WriteFiberFunc,&fs[WRITE_FIBER]);
if (g_lpFiber[WRITE_FIBER] == NULL)
{
printf("CreateFiber error (%d)\n", GetLastError());
return RTN_ERROR;
}
fs[WRITE_FIBER].dwParameter = 0x54545454;
//
// Switch to the read fiber
//
SwitchToFiber(g_lpFiber[READ_FIBER]);
代码省略了部分,最后一行的SwitchToFiber就是切协程。如果了解其他语言比如python的话,就知道在python的协程中切协程的api也叫switch。
不过微软的纤程实在是太难用了,就像张三丰在武当山上当众耍了一遍太极,张无忌和一万个人都看到了,结果只有张无忌学会了一样。张无忌能学会是因为他本身有九阳神功和乾坤大挪移做内功,有这个基础再加上天赋,去学其他东西就易如反掌。使用Fiber则需要非常深厚的编程实力,除了一些顶级程序员外,其他人用Fiber差不多是搬石头砸自己脚。虽然Fiber推出的时间非常早,但一直都默默无闻。
好在后来golang把协程发扬光大,不过那是后话了。