go语言调度器源代码情景分析之九:操作系统线程及线程调度

原创 爱写程序的阿波张 源码游记 2019-04-25

本文是《go调度器源代码情景分析》系列 第一章 预备知识的第九小节。

要深入理解goroutine的调度器,就需要对操作系统线程有个大致的了解,因为go的调度系统是建立在操作系统线程之上的,所以接下来我们对其做一个简单的介绍。

很难对线程下一个准确且易于理解的定义,特别是对于从未接触过多线程编程的读者来说,要搞懂什么是线程可能并不是很容易,所以下面我们抛开定义直接从一个C语言的程序开始来直观的看一下什么是线程。之所以使用C语言,是因为C语言中我们一般使用pthread线程库,而使用该线程库创建的用户态线程其实就是Linux操作系统内核所支持的线程,它与go语言中的工作线程是一样的,这些线程都由Linux内核负责管理和调度,然后go语言在操作系统线程之上又做了goroutine,实现了一个二级线程模型。

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
36
37
38
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

#define N (1000 * 1000 * 1000)

volatile int g = 0;

void *start(void *arg)
{
int i;

for (i = 0; i < N; i++) {
g++;
}

return NULL;
}

int main(int argc, char *argv[])
{
pthread_t tid;

// 使用pthread_create函数创建一个新线程执行start函数
pthread_create(&tid, NULL, start, NULL);

for (;;) {
usleep(1000 * 100 * 5);
printf("loop g: %d\n", g);
if (g == N) {
break;
}
}

pthread_join(tid, NULL); // 等待子线程结束运行

return 0;
}

该程序运行起来之后将会有2个线程,一个是操作系统把程序加载起来运行时创建的主线程,另一个是主线程调用pthread_create创建的start子线程,主线程在创建完子线程之后每隔500毫秒打印一下全局变量 g 的值直到 g 等于10亿,而start线程启动后就开始执行一个10亿次的对 g 自增加 1 的循环,这两个线程同时并发运行在系统中,操作系统负责对它们进行调度,我们无法精确预知某个线程在什么时候会运行。

关于操作系统对线程的调度,有两个问题需要搞清楚:

  1. 什么时候会发生调度

  2. 调度的时候会做哪些事情?

首先来看第一个问题,操作系统什么时候会发起调度呢?总体来说操作系统必须要得到CPU的控制权后才能发起调度,那么当用户程序在CPU上运行时如何才能让CPU去执行操作系统代码从而让内核获得控制权呢?一般说来在两种情况下会从执行用户程序代码转去执行操作系统代码:

用户程序使用系统调用进入操作系统内核;

硬件中断。硬件中断处理程序由操作系统提供,所以当硬件发生中断时,就会执行操作系统代码。硬件中断有个特别重要的时钟中断,这是操作系统能够发起抢占调度的基础。

操作系统会在执行操作系统代码路径上的某些点检查是否需要调度,所以操作系统对线程的调度也会相应的发生在上述两种情况之下。

下面来看一下在笔者的单核电脑上运行该程序的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
bobo@ubuntu:~/study/c$ gcc thread.c -o thread -lpthread
bobo@ubuntu:~/study/c$ ./thread
loop g: 98938361
loop g: 198264794
loop g: 297862478
loop g: 396750048
loop g: 489684941
loop g: 584723988
loop g: 679293257
loop g: 777715939
loop g: 876083765
loop g: 974378774
loop g: 1000000000

从输出可以看出,主线程和start线程在轮流着运行,这是操作系统对它们进行了调度的结果,操作系统一会儿把start线程调度起来运行,一会儿又把主线程调度起来运行。

从程序的输出结果可以看到抢占调度的身影,因为主线程在start线程运行过程中得到了运行,而start线程执行的start函数根本没有系统调用,并且这个程序又运行在单核系统中,没有其它CPU来运行主线程,所以如果没有中断时发生的抢占调度,操作系统就无法获取到CPU的控制权,也就不可能发生线程调度。

接下来我们再来看看操作系统在调度线程时会做哪些事情。

如上所述,操作系统会把不同的线程调度到同一个CPU上运行,而每个线程运行时又都会使用CPU的寄存器,但每个CPU却只有一组寄存器,所以操作系统在把线程B调度到CPU上运行时需要首先把刚刚正在运行的线程A所使用到的寄存器的值全部保存在内存之中,然后再把保存在内存中的线程B的寄存器的值全部又放回CPU的寄存器,这样线程B就能恢复到之前运行的状态接着运行。

线程调度时操作系统需要保存和恢复的寄存器除了通用寄存器之外,还包括指令指针寄存器rip以及与栈相关的栈顶寄存器rsp和栈基址寄存器rbp,rip寄存器决定了线程下一条需要执行的指令,2个栈寄存器确定了线程执行时需要使用的栈内存。所以恢复CPU寄存器的值就相当于改变了CPU下一条需要执行的指令,同时也切换了函数调用栈,因此从调度器的角度来说,线程至少包含以下3个重要内容:

一组通用寄存器的值

将要执行的下一条指令的地址

所以操作系统对线程的调度可以简单的理解为内核调度器对不同线程所使用的寄存器和栈的切换。

最后,我们对操作系统线程下一个简单且不准确的定义:操作系统线程是由内核负责调度且拥有自己私有的一组寄存器值和栈的执行流。

最后,如果你觉得本文对你有帮助的话,麻烦帮忙点一下文末右下角的 在看 或转发到朋友圈,非常感谢!

-------------本文结束 感谢阅读-------------