这项基本功,你掌握的够扎实么?

本文节选自 Allen 在 GitChat 的分享

Allen

前言


一直以来,我们学习线程切换与调度,都是通过阅读操作系统书籍或 Linux 源码甚至反汇编 Window 内核代码。无论怎样,这些东西都很抽象,学习起来十分困难。另外,随着现代化编程语言逐渐流行,C++20 的正式发布,都离不开用户态线程这些基础知识。再比如 Golang 的 Goroutine,腾讯公司的开源的 libco,百度的 BRPC 中的 bhtread,如果想深刻理解它们,都需要扎实的基本功。

本文会带你循序渐近的掌握下面的知识点:

  • 控制流切换原理

  • 上下文切换

  • 线程设计

  • 调度函数的封装与代码模块化

  • 线程的主动切换

  • 时间片轮转调度


本文实验环境:

  • ubuntu 16.04 32 位操作系统(读者请务必提前安装好);

  • 挑选一个你自己觉得好用的虚拟机软件,比如 VMWare;

  • 请把你的虚拟机环境配置成单核 CPU。


学习时间:大于 5 小时

为什么使用 32 位系统?因为方便初学者学习,能更快速的掌握原理。

最终我们实验完成的效果应该是下面这个样子,动态图地址:

https://images.gitbook.cn/ad977030-47b0-11e9-b53a-b750340c4d0d

640?wx_fmt=png

图1 用户态线程运行示例

需要注意的是,上面的代码,并没有使用操作系统为我们提供的 pthread 系列函数,thread_create 和 thread_join 函数都是自己纯手工实现的。唯一使用操作系统的函数就是设置时钟,因此会有时钟信号产生,这一步是为了模拟时间片轮转算法而做的。

下面是图1 中的 demo 示例代码:

 
 

#include <stdio.h>
#include <stdlib.h>
#include "thread.h"

void fun1() {
int i = 10;
while(i--) {
printf("hello, I'm fun1\n");
mysleep(2);
}
}

void fun2() {
int i = 10;
while(i--) {
printf("hello, I'm fun2\n");
mysleep(1);
}
}

void fun3() {
int i = 2;
while(i--) {
printf("hello, I'm fun3\n");
mysleep(5);
}
}

void fun4() {
int i = 15;
int m;
int n;
while(i--) {
printf("hello, I'm fun4\n");
for (m = 0; m < 10000; ++m)
for (n = 0; n < 10000; ++n);
}
}

int main() {

int tid1, tid2, tid3, tid4;
thread_create(&tid1, fun1);
printf("create thread %d\n", tid1);
thread_create(&tid2, fun2);
printf("create thread %d\n", tid2);
thread_create(&tid3, fun3);
printf("create thread %d\n", tid3);
thread_create(&tid4, fun4);
printf("create thread %d\n", tid4);

int i = 2;
while(i--) {
printf("hello, I'm main\n");
mysleep(3);
}
thread_join(tid1);
thread_join(tid2);
thread_join(tid3);
thread_join(tid4);

return 0;
}

控制流切换原理

控制流,指的是一系列按顺序执行的指令。多控制流,是指存在两个或两个以上可以并发(宏观同时,微观不同时)执行的指令序列。比如你编写的多线程程序,每个线程就可以看成是一个控制流,多个线程允许多个控制流一起执行。

在我们学习编程的时候,如果不借助操作系统提供的线程框架,几乎无法完成多控制流的运行的。

接下来先来剖析一下,我们的指令如何”莫名奇妙“的就切换到其它线程的。

1.1 指令执行

不管你用的是什么语言编程,最后都要落实到 CPU 上,而 CPU 只认识它自己的语言,机器语言。机器语言可以抽象出对应 CPU 架构的汇编指令。如下面的 x86 指令序列。

 
 

//...
mov eax, 5
push eax
call 0x00401020
add 0x4
//...

程序在执行时,实际上就是汇编指令(准确的说是机器指令)在 CPU 上一条一条执行。对于单核 CPU 来说,永远只有一条控制流,也就是只有一条指令序列。所以,宏观上模拟的多线程程序,本质上还只是单控制流,所谓的多线程,只不过是一种被制造出来的假像!

注:有部分同学没有接触过汇编指令,不要害怕,我们用到的汇编不会太难!

1.2 控制流切换(x86 CPU 架构)

汇编指令在执行的时候,最重要地方在于它需要依赖 CPU 环境:

  • 一套通用寄存器 (eax、edx、ecx、ebx、esp、ebp、esi、edi);

  • 标志寄存器 eflags;

  • 指令寄存器 eip (eip 用来保存下一条要被指令的地址)。

如果你不理解 CPU 寄存器是什么意思,把它想象成它是 CPU 中预先定义好的变量。

还有一个很重要环境,就是。因为指令序列在执行时,经常会保存一些临时数据,比如某条指令的地址。当指令执行 ret 指令的时候,CPU 会从当前栈顶弹出一个值到 eip 寄存器!这意味着要发生跳转了!

通用寄存器中,有一个寄存器名为 esp,它保存的是栈顶指针(内存地址的值)。指令 push 、 pop、call、ret 都依赖于 esp 工作。

  • call 指令把它后面的指令地址保存到 esp 指向的内存单元,同时修改 eip。如 call 0x2000,先把 call 0x2000 的下一条指令地址压栈,同时修改 eip 为 0x2000。

  • ret 指令把 esp 指向的内存单元中的值保存到 eip。

  • push 指令把值保存到 esp 指向的内存单元。

  • pop 把 esp 指向的内存单元的值取出。

640?wx_fmt=png

图2 CPU 寄存器 esp 与内存单元的关系,右侧表示运行栈


想象一下,如果某个时候,我们把 esp 保存的数据 “偷偷” 换了,换句话说我们是把栈换了,而栈中保存的那个“某条指令”的地址的值也不一样了,将会发生什么?图3 把 esp 的值从 0xFFE8 更改成了 0x1FFE8。

640?wx_fmt=png

图3 切换 esp 即切换栈


所谓的切换控制流,无非就是更改 esp 栈顶指针来做到偷梁换柱的把戏而已。只不过,为了做到惟妙惟肖,我们在更改 esp 的时候,还得顺带的把通用寄存器环境修改修改,以适应新的那段“某条指令”的执行环境。(注意,栈中经常会保存某条指令的地址,比如函数的返回地址。)

通常,这段新的“某条指令”的执行环境,恰好也保存在栈里,就像上图中 esp 到“某条指令地址”之间那段内存的数据(五颜六色的那部分数据)。

说了这么多很抽象,我们来一个具体的例子。简单讲解一下,更改栈中保存“某条指令地址”的后果。

 
 

// test.c
// 编译方式:gcc test.c -fno-stack-protector

#include <unistd.h>
#include <stdio.h>

void fun() {
while(1) {
printf("Hello, I'm fun!\n");
sleep(1);
}
}

int main() {
int a[5] = { 0 };
// 传说中的溢出攻击
a[5] = (int)fun;
a[6] = (int)fun;
a[7] = (int)fun;
return 0;
}

编译和运行:

 
 

$ gcc test.c -fno-stack-protector
$ ./a.out

640?wx_fmt=png

图4 溢出攻击


这段代码非常短,但却很典型。让我们来看看到底是怎么回事。图 5 左侧是 test.c 程序编译成汇编指令的结果。

编译方法:

 
 

$ gcc -S test.c -fno-stack-protector

注:-fno-stack-protector 是为了防止编译器插入栈保护检查代码。

640?wx_fmt=png

图5 溢出攻击指令分析


为了方便看结果,我已经删除了部分影响“视觉效果”的代码。可以看到后面的越界赋值导致蓝色框框中的数据被破坏,导致“某条指令地址”(实际上是 main 函数的返回地址)被更改为 fun 函数地址,也就是图 5 中汇编第 4 行指令 pushl %ebp 这条指令的地址。

当指令执行到第 34 行的 ret 时,栈是下面的样子:

640?wx_fmt=png

图6 执行到 ret 指令时栈的“容貌”


ret 指令等价于 pop eip,也就是把栈顶的值送入 eip 寄存器。于是,程序就跳转到了 fun函数中执行,形成图 4 中的效果。

如果你阅读上面的执行流程感觉困难,建议你先读一读有关函数执行流程的知识,在文章《打造自己的 longjmp》中有很详细的介绍,推荐你阅读。

1.3 再论控制流切换

在你彻底明白“溢出攻击”实验的原理后,我们换个思路,不修改栈的内容,而是直接换一个栈,本质上也就是换 esp 的值,能不能达到相同的效果?比方说,新的栈里的内容,是我事先构造好的。再看一个实验。

 
 

// test2.c
// 编译方式:gcc test2.c
#include <unistd.h>
#include <stdio.h>

void fun() {
while(1) {
printf("Hello, I'm fun!\n");
sleep(1);
}
}

int main() {
int stack[64] = {0};
stack[63] = (int)fun;
// 新栈的栈顶指针
int new_esp = (int)(stack+63);

__asm__ (
"mov %0, %%esp\n\t"
"ret\n\t"
::"m"(new_esp));
/* 上面的这段内联汇编翻译成 x86 汇编是这样的:
mov esp, new_esp 切换栈顶指针
ret 返回
*/

return 0;
}

编译和运行:

 
 

$ gcc test2.c
$ ./a.out

执行效果和图 4 中是一模一样的。

640?wx_fmt=png

图7 手工修改 esp


这个实验和“溢出攻击实验”区别就是不再修改栈内容,而是使用我们自己构造的新栈,以达到相同的控制流切换的效果。这里就不再画栈的样子了,留给读者自己分析。

话说回来,这个真的是控制流“切换”吗,只是看起来像而已,本质上它只是个跳转。

上下文切换

当你完全明白了第 1 节的内容后,现在我们尝试一些新的东西。否则继续往后阅读你可能会有些吃力。

在上一小节中已经用 C 语言和汇编分别完成了两个小实验,告诉你如何通过更改栈来达到控制流转向你所期望的目的地。不过,这只是切换出去,要完成线程调度,最关键的一点在于还得切换回来。

2.1 上下文切换原理

上下文切换不同于第 1 节所述的暴力切换,因为在那个实验里,我们永远无法重新返回到 main 函数中。如果你想从那个 fun 函数再跳回目的地,我们需要在切换控制流前保存当前寄存器环境,以及当前的栈顶位置。

上面那段话是说,当我们什么时候想切换回来的时候,只要更改一下栈(这个你已经学会了)同时在恢复寄存器环境,看起来就好像从以前切出去的那个位置继续执行。

2.2 保存寄存器环境

有很多种手段保存寄存器环境,最简单的一种就是保存到定义好结构体去。假设我们有 3 个线程,那就需要 3 结构体变量,分别保存自己的寄存器环境。

 
 

struct context {
int eax;
int edx;
int ecx;
int ebx;
int esp;
int ebp;
int esi;
int edi;
int eflags;
}

三个线程对应的结构体数组是 struct context ctx[3]。当我们从线程 0 切换到线程 1 的时候,我们就将线程 0 当前的寄存器环境保存到 ctx[0] 里去。什么时候我们重新切换回线程 0 的时候,再把 ctx[0] 中的值恢复到所有寄存器中。

640?wx_fmt=png


图8 从 “线程0” 切换到 “线程1”


上面的过程用汇编很容易实现,不过在实际的实现版本中,没有采用这种方法,而是使用了更加简洁的方法——将当前寄存器的环境保存在当前所使用的栈中。具体过程见图 9。

640?wx_fmt=png


图9 从 “线程0” 切换到 “线程1”


图 9 中的步骤可以叙述为下:

  • 线程 0 (请允许我称此为线程吧)正准备切换时,将当前 CPU 中的寄存器环境一个一个压入到自己的栈中,最后一个压栈的是 eflags 寄存器;

  • 线程 0 将自己的栈顶指针(保存 eflags 的那个位置)保存到全局数组 task[0] 中;

  • 线程 0 从全局数据 task 中取出下一个线程的栈顶,假设下一个要运行的线程是 1 号线程,则从 task[1] 中取出线程 1 的栈顶指针保存到 CPU 的 esp 寄存器中。此时意味着栈已经被切换。栈切换完成后,本质上已经在线程 1 中了;

  • 线程 1 将自己栈中的寄存器环境 pop 到对应的 CPU 寄存器中,比如第一个 pop 到 eflags 中,最后一个是 pop ebp。

2.3 上下文切换实验

你需要创建两个文件,分别是 main.c 和 switch.s。

 
 

// main.c
#include <unistd.h> // for sleep
#include <stdio.h>

int task[3] = {0, 0, 0}; // for esp
int cur = 0; // current esp
void switch_to(int n); // 定义在 switch.s 中

void fun1() {
while(1) {
printf("hello, I'm fun1\n");
sleep(1);
// 强制切换到线程 2
switch_to(2);
}
}

void fun2() {
while(1) {
printf("hello, I'm fun2\n");
sleep(1);
// 强制切换到线程 1
switch_to(1);
}
}

// 线程启动函数
void start(int n) {
if (n == 1) fun1();
else if(n == 2) fun2();
}

int main() {
int stack1[1024] = {0};
int stack2[1024] = {0};
task[1] = (int)(stack1+1013);
task[2] = (int)(stack2+1013);

// 创建 fun1 线程
// 初始 switch_to 函数栈帧
stack1[1013] = 7; // eflags
stack1[1014] = 6; // eax
stack1[1015] = 5; // edx
stack1[1016] = 4; // ecx
stack1[1017] = 3; // ebx
stack1[1018] = 2; // esi
stack1[1019] = 1; // edi
stack1[1020] = 0; // old ebp
stack1[1021] = (int)start; // ret to start
// start 函数栈帧,刚进入 start 函数的样子
stack1[1022] = 100;// ret to unknown,如果 start 执行结束,表明线程结束
stack1[1023] = 1; // start 的参数

// 创建 fun2 线程
// 初始 switch_to 函数栈帧
stack2[1013] = 7; // eflags
stack2[1014] = 6; // eax
stack2[1015] = 5; // edx
stack2[1016] = 4; // ecx
stack2[1017] = 3; // ebx
stack2[1018] = 2; // esi
stack2[1019] = 1; // edi
stack2[1020] = 0; // old ebp
stack2[1021] = (int)start; // ret to start
// start 函数栈帧,刚进入 start 函数的样子
stack2[1022] = 100;// ret to unknown,如果 start 执行结束,表明线程结束
stack2[1023] = 2; // start 的参数

switch_to(1);
}

 
 

// switch.s
/*void switch_to(int n)*/

.section .text
.global switch_to // 导出函数 switch_to
switch_to:
push %ebp
mov %esp, %ebp /* 更改栈帧,以便寻参 */

/* 保存现场 */
push %edi
push %esi
push %ebx
push %edx
push %ecx
push %eax
pushfl

/* 准备切换栈 */
mov cur, %eax /* 保存当前 esp */
mov %esp, task(,%eax,4)
mov 8(%ebp), %eax /* 取下一个线程 id */
mov %eax, cur /* 将 cur 重置为下一个线程 id */
mov task(,%eax,4), %esp /* 切换到下一个线程的栈 */

/* 恢复现场, 到这里,已经进入另一个线程环境了,本质是 esp 改变 */
popfl
popl %eax
popl %edx
popl %ecx
popl %ebx
popl %esi
popl %edi

popl %ebp
ret

640?wx_fmt=png

图10 上下文切换实例运行结果,动态图地址https://images.gitbook.cn/5ef27c30-47c5-11e9-9afc-37690262ac34 

2.4 程序分析

实验的难点在于第一次切换到另一个线程时,那个线程的上下文并不存在。所以在 main 函数中,我们要事先构造出要被切换的那些线程的上下文。

特别注意的是,为了方便管理所有的线程回调函数 fun1 和 fun2,这里借助了一个 start 函数来统一管理它们,这样一来,每次构造环境的代码就可以统一起来。窍门在于 main 函数中的初始环境的构造。以 fun1 为例。

 
 

// 创建 fun1 线程
// 初始 switch_to 函数栈帧
stack1[1013] = 7; // eflags
stack1[1014] = 6; // eax
stack1[1015] = 5; // edx
stack1[1016] = 4; // ecx
stack1[1017] = 3; // ebx
stack1[1018] = 2; // esi
stack1[1019] = 1; // edi
stack1[1020] = 0; // old ebp
stack1[1021] = (int)start; // ret to start
// start 函数栈帧,刚进入 start 函数的样子
stack1[1022] = 100;// ret to unknown,如果 start 执行结束,表明线程结束
stack1[1023] = 1; // start 的参数

640?wx_fmt=png

图11 构造线程 1 的运行栈的样子


为什么要填充 0~7 这些数字呢?其实这些数据本身并没有意义,单纯的只是为了调试方便。

当 main 函数执行到 switch_to(1) 的时候,注意进入 switch_to 里面时,switch_to 的前半段(图 12 中第 6 行到 第 22 行),使用的栈都还是主线程的栈,第 6 行到第 16 行将当前寄存器环境保存到主线程的自己的栈中,如图 11 中右侧的栈。

640?wx_fmt=png

图12 switch.s 代码


第 19 到第 23 行,相当于:

 
 

eax = cur; // cur 是全局变量,保存当前“线程” id
task[eax] = esp; // mov %esp, task(,%eax,4)
eax = n; // n 是 switch_to 函数的参数,保存在 8(%ebp) 这个位置
cur = eax;
esp = task[cur]; // mov task(,%eax,4), %esp

执行图 12 中的第 23 行时,正是栈的切换操作,这一行执行完成后,当前运行栈就变成了图 11 中左侧的栈。接下来的 26 开始,就已经算是进入了另一个“线程”了。

很奇妙吧,一个 switch_to 函数竟然同时跨越了 2 个“线程”,其本质就是栈变了。

从 26 行开始,一连串的 pop 动作将栈中的值弹到 cpu 寄存器中。我们在构造的时候,只是随便填了一些值,因为这并不会有任何影响,你继续跟踪代码就知道了。switch_to 函数执行到 ret 指令的时候,esp 这个时候指向的是 stack1[1021] 这个位置,一旦 ret,就进入了 start 函数,这个技巧在上一篇文章你早已学会。


 识别二维码阅读全文

640?wx_fmt=png


后文中还将带你循序渐近的掌握下面的知识点:

  1. 线程设计

  2. 调度函数的封装与代码模块化

  3. 线程的主动切换

  4. 时间片轮转调度

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值