浅谈阻塞以及协程

本文最后更新于:2022年12月19日 晚上

序、提出的问题

在正式开始之前,我想先提出几个问题,然后一起带着问题以及思考去阅读这篇文章

1、什么是协程

2、协程的作用是什么

一、阻塞与非阻塞

首先我们来回顾一些基础的概念,对于操作系统来说,什么是阻塞和非阻塞呢?

阻塞和非阻塞的区别在于:是否立即返回数据,在非阻塞的模型中,应用发起请求(比如IO请求后),系统会立即返回,程序可以再次发起请求,直到系统处理完毕,返回数据。

这里写了一个小的demo,实现非阻塞IO读取本地文件,可以从代码中看出,在读取文件并且未读取完成的时候,系统会返回EAGAIN,线程不断地轮询,直到系统读取完成并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>

#define MSG_TIMEOUT "timeout\n"

int main() {
char buf[1024];
long n;
int i = 0, fd;
int timeout = 100;
fd = open("/Users/admin/neo/tmp/test.txt", O_RDONLY | O_NONBLOCK);
if (fd < 0) {
exit(1);
}
for (; i < timeout; ++i) {
n = read(fd, buf, 1024);
if (n >= 0) {
break;
}
if (errno != EAGAIN) {
exit(1);
}
sleep(1);
}
if (i == timeout) {
write(STDOUT_FILENO, MSG_TIMEOUT, strlen(MSG_TIMEOUT));
} else {
write(STDOUT_FILENO, buf, n);
}
close(fd);
return 0;
}

二、多路复用

即使是非阻塞模型,应用的每一个请求,依然需要创建一个线程去处理,这显然也不是一种很优雅的解决方法。如何用少的线程管理多的请求呢,答案是多路复用。

在多路复用的场景下,系统将请求的可写可读操作分离了出来,使用单独的线程进行管理。举个例子,系统可以创建3个线程处理6个请求,当6个请求进入的时候,会经过多路复用器,多路复用器会阻塞请求线程,同时轮询是否有空闲线程,如果有空闲线程的话就分配并进行读写操作。

虽然线程在读写时依然是阻塞的,但是配合非阻塞的IO操作,依然可以实现高并发下的网络连接,这就是reactor模型。

三、异步

不难发现即使是多路复用,依然有阻塞的产生,异步的概念随之而来。与异步相关的另一个名词:回调,可以很好地解释异步这个概念。异步在发起请求后不会阻塞,而是会继续处理其他请求,当系统IO处理完成后,会通知程序处理返回。程序不用阻塞、反复地轮询是否ready,可以直接切换到其他任务,等待回调即可。

不仅是异步,在上述的多路复用模型中,都有等待数据返回这一个操作,当多个事件都需要回调时,代码会变得非常复杂。在Java中的Netty框架封装了基于nio(同步非阻塞)的多路复用,让IO的调用变得快速且简单,同时它也有另外一种解决方法:协程。

四、什么是协程

A coroutine is a function that can suspend its execution (yield) until the given given YieldInstruction finishes.

首先想要说明,在我个人的理解里,协程更多的是一种技术的概念,而非一种技术的实现,协程没有一个统一的定义,通俗地讲:协程就是一个基于用户态的轻量的线程,它可以实现线程的一些功能,但又对kernel不可见,调度都由程序控制。

如果熟悉操作系统的话,我们可以知道,系统对线程的调度是抢占式的,即线程的资源控制权利在系统,不在用户。协程便可以实现协作式的调度,即用户有权利控制自己占有多久协程。

ps:协作式的调度会出现一个协程占用过多的时间,不交出资源的情况,golang在1.14引入了抢占式协程调度,而kotlin对协程的实现依然是协作式的,由此来看,协程更多的只是一个概念,每个人,每个语言,都可以用不同的方式去实现,js等语言的async await的实现我认为也可以算协程,即使它是无栈协程。有栈和无栈的协程相关知识不在这里展开,感兴趣的话可以查看文章:有栈协程与无栈协程

因此协程的开启以及切换的开销和线程对比起来,都小了很多。用线程的话可能我们最多只能开启几千个线程,但是协程我们可以做到几万甚至更多。

提到协程,我们一瞬间基本上都能想到以下几个优点

  1. 节省内存
  2. 节省线程创建时syscall的开销
  3. 协程的切换开销很小
  4. 可以配合非阻塞IO,提高系统性能

五、协程的作用是什么

上文讨论了很多IO以及协程相关的问题,现在我们对看到协程都会形容它:轻量级的线程。对于我们程序员来说,这个轻量级的线程最大的作用是什么:提高程序速度?毕竟它比线程轻,能开几十上百万的协程。但协程最大的作用是:对异步以及回调的封装,让程序员可以用写同步代码的方法写异步IO。我们可以看一段golang的代码:

1
2
3
4
5
6
//省略
func main() {
resp, _ := http.Get("https://xxxxxx")
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}

这段代码发起了一个网络IO请求,得到结果并输出,这是一个我们再熟悉不过的同步代码书写,但这其实是一个异步/回调的执行,在发起http请求后,go创建了一个新的协程去处理网络请求,并让出当前线程的资源去进行其他任务,当请求完成后再通过线程调度协程恢复,继续进行后续的处理。

让我们回到文章的开头,思考阻塞与非阻塞。当时提到:对操作系统来说,讲到这应该都清楚一件事,那就是线程是内核可以感知的,而协程是内核无法感知的,所以协程的”阻塞”,对于操作系统以及线程而言,都是”非阻塞”的状态,对于golang来说,当一个协程进行IO操作的时候,golang会在用户态阻塞这个协程,内核态是无感的,golang将这个协程yield出去,将资源分配给其他的go协程。这里的重点也是:协程的切换代价比线程的切换低了很多。