教程:纯手工实现多线程

本系列文章旨在记录完成一个小型用户级线程创建、切换与调度框架的过程,并不能代替操作系统为我们提供的线程框架。

为什么要写这样的东西呢?目的很简单,仅仅在于学习操作系统中的进程或线程切换与调度原理。所以,请不要把代码试图用到你的工程或者项目中,出了问题也不要来找我啊。

如果你发现该项目中存在的问题或 bugs,请不要吝啬,及时联系我,因为我也只是一个学习者,希望共同进步。

[注]

  • 阅读本系列文章需要的基础:简单的汇编语言基础,知道 call、ret 的工作原理。
  • 实验环境:32 位 linux 系统 + gcc 编译器。
  • QQ讨论群:610441700
  • 项目代码 gitos 托管地址(支持国产^_^):http://git.oschina.net/ivan_allen/Thread

重要提示: 请不要忘记给项目点 star 啊!!!还有一点,代码中可能存在 bug,请不要忘记在博客后面评论。


应该说一直以来,我们学习线程切换与调度,都是通过阅读操作系统书籍或 Linux 源码甚至反汇编 Window 内核代码。无论怎样,这些东西都很抽象,学习起来十分困难。

本系列文章,准确的说是实验指导书吧,会带你循序渐近的掌握下面的知识点:

  • 控制流如何切换
  • 完成一个简单的暴力跳转
  • 封装 create_thread 函数
  • 抽取调度函数
  • 模块化项目
  • 封装阻塞类函数,以 sleep 函数为代表
  • 时钟与时间片概念
  • 为线程添加时间片
  • 时间片轮转+优先级调度

注意上面不是目录,只是我们将要遇到的一些关键节点。最后,我们实现的效果应该是下面这个样子:



这里写图片描述
图1 最终的效果 


客户端完整代码见图 2.

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

接下来,废话不多说了,开启新篇章吧《控制流切换原理》;



这里写图片描述  
图 2 使用自己的编写的多线程框架创建线程 #百度##C++工程师#
全部评论
沙发,棒棒哒
点赞 回复
分享
发布于 2017-05-18 08:42
群主出山了
点赞 回复
分享
发布于 2017-05-18 09:15
滴滴
校招火热招聘中
官网直投
控制流,指的是一系列按顺序执行的指令。多控制流,是指存在两个或两个以上可以并发(宏观同时,微观不同时)执行的指令序列。比如你编写的多线程程序,每个线程就可以看成是一个控制流,多个线程允许多个控制流一起执行。 在我们学习编程的时候,如果不借助操作系统提供的线程框架,是无法完成多控制流的运行的。 本文我们先来剖析一下,我们的指令如何”莫名奇妙“的就切换到其它线程的。 1. 指令执行 不管你用的是什么语言编程,最后都要落实到 CPU 上,而 CPU 只认识它自己的语言,机器语言。机器语言对应的就是汇编指令。如下面的 x86 指令序列。 ... mov eax, 5 push eax call 0x00401020 add 0x4 ... 程序在执行时,本质上就是汇编指令在 CPU 上一条一条跑。对于单核 CPU 来说,永远只有一条控制流,也就是只有一条指令序列。所以,宏观上模拟的多线程程序,本质上还只是单控制流,所谓的多线程,只不过是一种被制造出来的假像! 2. 控制流切换 汇编指令在执行的时候,最重要地方在于它需要依赖 CPU 环境: 一套通用寄存器 (eax, edx, ecx, ebx, esp, ebp, esi, edi) 标志寄存器 eflags 指令寄存器 eip (eip 用来保存下一条要被指令的地址) 还有一个很重要环境,就是栈!因为指令序列在执行时,经常会保存一些临时数据,比如某条指令的地址。当指令执行 ret 指令的时候,cpu 会从当前栈顶弹出一个值到 eip 寄存器!这意味着要发生跳转了! 通用寄存器中,有一个寄存器名为 esp,它保存的是栈顶指针。指令 push 、 pop、call、ret 都需要依赖于 esp 工作。 call 指令把它后面的指令地址保存到 esp 指向的内存单元,同修改 eip ret 指令把 esp 指向的内存单元中的值保存到 eip push 指令把值保存到 esp 指向的内存单元 pop 把 esp 指向的内存单元的值取出。 图1 esp 与内存单元的关系 想象一下,如果某个时候,我们把 esp “偷偷”换了,换句话说我们是把栈换了,而栈中保存的那个“某条指令”的地址的值也不一样了,将会发生什么? 图2 更改 esp 寄存器的值做到栈切换 所谓的切换控制流,无非就是在更改 esp 栈顶指针来做到偷梁换柱的把戏而已。只不过,为了做到惟妙惟肖,我们在更改 esp 的时候,还得顺带的把通用寄存器环境修改修改,以适应新的那段“某条指令”的执行环境。 通常,这段新的“某条指令”的执行环境,恰好也保存在栈里,就像图 2 中 esp 到“某条指令地址”之间那段内存的数据。 说了这么多很抽象,我们来一个具体的例子。简单讲解一下,更改栈中保存“某条指令地址”的后果。 3. 修改栈改变程序执行流程 3.1 程序清单 代码 // test.c #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 $ ./a.out 运行结果 图3 修改栈中数据 3.2 结果分析 图 4 中,左侧部分是上面 C 语言代码的反汇编部分。中间的栈是原本的样子,也就是还没执行 a[5] = fun, a[6] = fun 以及 a[7] = fun 的样子。被蓝色虚线框起来的是进入 main 函数的返回地址以及 main 函数的参数。最右边的栈,是被修改后的栈。 图4 指令序列分析 可以看到后面的越界赋值导致蓝色框框中的数据被破坏,导致“某条指令地址”被更改为 fun 函数地址,也就是图 4 中汇编第 4 行指令 pushl %ebp 这条指令的地址。 当指令执行到第 31 行的 ret 时,栈是图 5 的样子: 图5 执行到 31 行 ret 指令时栈的样子 ret 指令等价于 pop eip,也就是把栈顶的值送入 eip 寄存器。于是,程序就跳转到了 fun 函数中执行,形成图 3 中的效果。 如果你阅读上面的执行流程感觉困难,建议你先读一读有关函数执行流程的知识,在文章《打造自己的 longjmp》中有很详细的介绍。 4. 再论控制流切换 在你彻底明白第 3 节的实验后,我们换个思路,我们不修改栈的内容,而是直接换一个栈,本质上也就是换 esp 的值,能不能达到相同的效果?比方说,新的栈里的内容,是我事先构造好的。再看一个实验。 代码 #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 执行效果和图 3 中是一模一样的,这里就不贴图了。 这个实验和第 3 节中的区别就是不再修改栈内容,而是使用我们自己构造的新栈,以达到相同的控制流切换的效果。这里就不再画栈的样子了,留给读者自己分析。 这个真的是控制流“切换”吗,看起来像而已,本质上它只是个跳转。 5. 总结 理解“切换”的本质 掌握指令执行与栈的关系 练习1:完成本文中的两个实验。练习2:画出实验二中旧栈和新栈,分析是如何从旧栈切换到新栈的,以及是如何跳转到函数 fun 中去的。
点赞 回复
分享
发布于 2017-05-18 11:33

相关推荐

4 18 评论
分享
牛客网
牛客企业服务