Goroutine是个好东西,它方便的特性极大降低了我们的心智负担。但我们必须意识到它的本质是用户态线程——还是未实现可剥夺的那种。而这可能会带来负面的影响。之前有人考过我,使用Goroutine和使用传统线程有什么区别,我想这大概也算其中之一。

做个实验:

实验平台CPU:Intel Core i5-3210M
操作系统:Linux version 4.19.23

首先是一段C++代码。在这段代码里,main函数发起十个后台线程,接着等待五秒后打印“quit”并返回。而每个线程会打印其编号(0-9)并进入死循环。

// main.cpp

#include <iostream>
#include <thread>
#include <unistd.h>

using namespace std;

int f(int i){
  cout << i <<endl;
  // 死循环
  while(true)
  {
    ;
  }
}

int main()
{
  for(int i = 0; i < 10; i++)
  {
    thread t(f,i);
    t.detach();
  }

  sleep(5);
  cout << "quit" << endl;
}

这段代码的执行结果是什么呢?

cpp_threads

程序成功输出10个数字。当然,由于线程调度的不确定性,数字出现的顺序是无规律的。而由于数据竞争的关系,甚至可能会出现空行,或同一行挤着多个数字。进程会伴随主线程等待五秒后退出,后台线程也会停止运行。一切都和我们熟识的线程特性相吻合。那么如果我们用Goroutine来实现类似代码呢?

// main.go

import (
    "fmt"
    "time"
)

func main() {
    for i := 0; i < 10; i++ {
        go f(i)
    }

    time.Sleep(time.Second * 5)
    fmt.Println("quit")
}

func f(i int) {
    fmt.Println(i)
    // 死循环
    for {
    }
}

其执行结果却会与C++版本明显不同:

go_goroutine

没错,只有四个数字会被输出(当然0-9中出现哪四个数字也是随机的),而进程并不会退出,甚至连main函数中的“quit”字符串都无缘打印。这是为什么呢?其实答案在篇头已经讲过:因为Goroutine并不是可剥夺的。

我们知道,Goroutine作为用户态线程,几乎所有调度代码都工作在保护模式,这固然带来切换效率和自由度,但也带来一个难题——我们平时接触的所有计算机都基于单线程的工作模型,现代操作系统可以利用中断来实现工作流的切换。这使我们不必过于纠结计算平台拥有几个逻辑核心——因为线程每次执行的时间片是有限的。Golang并没有实现类似中断的机制,那它如何实现工作流的切换呢?答案是:不能。

这个答案并不能使人满意,因为Golang显然拥有基于Goroutine的调度系统——否则如何实现天然的异步?但很可惜,Goroutine的调度精度非常低。请注意,系统线程的实现通常都是可剥夺的(当然也有少量不是,例如早期的Linux线程),操作系统可以强行暂停某个线程,并切换到另一个。 但Goroutine不是。 Golang Runtime并不能强制暂停执行到一半的某个Goroutine,我们必须等待这个Goroutine执行结束才能进入调度流程——除非它在内部调用了可以触发调度的某个API。这些API是相当常用的,例如所有IO操作,例如time.Sleep,以致你很难避开它。这常给我们带来“Goroutine可剥夺”这一错觉。

我们知道,Golang的任务调度基于G-M-P模型,所有G(Goroutine)包含的代码在具体执行的时候,绑定于某个M(Machine,指由Golang Runtime管理的系统线程)。Goroutine不可剥夺,M不断拿到时间片然后失去,但其上的G却可能始终处于死循环中——无论操作系统如何轮换正在执行的M,Go调度器都无法轮换该M绑定的G,此时Goroutine调度器等同失效。假设所有M都被死循环阻塞,那么再没有G有机会得到M,更没有机会得到时间片。此时从外在表现来看,Goroutine调度器等于不存在。

回到之前的话题,我的实验CPU为i5-3210M,只有四个逻辑核心。在没有手动设置GOMAXPROCS时,Go的默认M仅有4个,所以才会被实验代码轻松“卡死”。

在日常编码中,我们当然会尽量避免死循环——除了极少数情况,死循环都意味着Bug。但这个实验并非全无意义。我们有时的确会遇到长耗时的纯运算代码(例如大量数据的编码转换)。长耗时计算,在局部时间内与死循环是没有区别的。而在这些场景下,如果同时存在时间敏感的操作(例如交互响应),那我们就必须避免该操作所在线程被阻塞。