Tag Archives: 技术

Linux下的用户级多线程库 Fthread

1.Fthread简介

Linux下的用户级线程。不调用内核的任何线程功能。仅包含一句汇编,程序长度百行左右。非常简化的线程库。

2.线程简单说明

线程的特征
(1)并发执行。需要独立的程序计数器
(2)独立性。独立性弱于进程,可以互相影响。但是也有一定的独立性。因此必须为每个进程维护专属于线程的资源。
(3)协作性。线程不完全独立,都能使用进程的全局量、打开文件表等等资源。
(4)由协作性所引发的,同步问题。

教材所提及的线程包括的项:程序计数器、堆栈、寄存器集、子线程、状态。
Fthread对这些项做了简化,去掉了子线程和状态两项。
在线程间来回切换实际上就是对每个线程专属资源的切换,简单描述:开始线程1正在执行,在某一时刻线程切换信号产生,调度程序被唤醒,保存当前线程的专属资源,选择下一个执行的线程,并恢复其程序计数器、堆栈指针、相关寄存器值。线程即投入运行。

3.线程的专属资源

程序计数器:在32位机中即为EIP寄存器,包含下一条指令的地址。
堆栈与寄存器:与函数调用、局部变量等有关。
一个典型的Linux下进程内存分布见下图:(摘自《深入理解计算机系统》)

说明:
(1)不同于DOS等16位操作系统,Linux使用32位的虚拟地址空间。在操作系统课也学过,每个进程都独立地拥有4GB的虚拟内存空间。互相不影响。在这些系统中,指针变量是32位的,记录32位的地址值。不需要像在16位系统中一样通过段偏移寻址和DOS特有的near far限定符来访问内存。

(2)Linux下的进程,其内存模型是分段的,各个段有自己的属性,比如只读可写等等。大体分为代码文本段、数据段和堆栈段,以及一些系统保留区域和禁止访问的区域,比如0地址。所有的程序代码指令都存于代码段中,数据段存储全局变量与静态变量。又可细分出BSS未初始化的数据与已初始化的数据、常量区等等。Malloc所分配的空间位于数据段中,称为heap堆,是由低地址空间往高地址空间增长的。

非法访问了或者企图改写禁止的区域,将会产生一个段错误信号,SIGSEGV。这是指针编程中常出的错误。在Windows下通常表现为一个非法操作。举个简单的例子
main()
{
int *p=0;
*p=32;
}
试图访问0地址(NULL指针)引发段错误。
运用gdb调试器有助于发现这种错误。在Windows下也有gcc系列工具,Devcpp开发环境就用的是gcc编译器。在windows下同样能运用gdb发现和改正错误。

(3)函数调用和访问局部变量都需要用到堆栈(Stack)。局部变量空间分配在栈里,函数调用记录也存储在栈里。一些函数调用参数等也存在栈里。
堆栈是一种LIFO后进先出的数据结构。栈由地址高空间往地址低空间向下增长。每当有数据进栈的时候,栈顶指针往下移,数据进栈。出栈时,栈顶指针往上移,数据出栈。

(4)与堆栈相关的寄存器:
ESP寄存器:栈顶指针。
EBP寄存器:基址寄存器。主要用于访问局部变量和分隔函数调用记录

void holdtest()
{
int a = 3;
}

为例。调用该函数过后,堆栈的分布如下:

高地址
————————
返回地址
————————
原来的EBP值 <===========EBP的值
————————
a
————————<===========ESP的值
低地址

a=3这句赋值语句,在内部实际上是这样一句汇编指令来实现的
MOV [ebp-4],3
意思是把3存到ebp寄存器的值减去4所在的内存里。这里的ebp-4其实就是a的地址值。减4是因为a的类型是int,在32位系统中占4个字节。
由此可以看到访问局部变量是通过访问EBP寄存器完成的。ESP作为栈顶指针,也是非常重要的。

通过以上分析可以看出,线程切换时恢复了程序计数器EIP与EBP ESP寄存器,则函数调用记录、局部变量均恢复了。除了这三个寄存器,还需要保存和恢复EBX基址变址存器,ESI EDI源变址/目的变址寄存器。简单说明:EBX寄存器主要在计算存储器地址时作为基址寄存器,ESI EDI主要与DS联用用于确定数据段的内容。这些寄存器可以通过汇编指令来保存,也可以利用C标准库中的setjmp/longjmp, sigsetjmp/longjmp来保存与恢复。

4.Fthread说明

(1)程序组织
一共就两个文件,一个头文件声明fthread.h,相关函数实现和一些辅助函数 fthread.c。

(2)线程队列
线程队列作为进程的全局量。线程结点定义如下:

typedef struct fthread
{
int tid; /* the ID of thread */
sigjmp_buf jmp_status; /* store the info of thread */
void *sp; /* point to the own stack */
void (*callback)(void *); /* the callback procedure */
void *param;
struct fthread *next; /* point to the next fthread object */
} fthread;

Tid字段是线程的唯一标识。

Jmp_status字段是sigjmp_buf型的,其原型定义在标准库文件setjmp.h中,作用就是保存程序计数器、堆栈指针、相关寄存器。sigsetjmp将这些信息存入这个字段,由siglongjmp 恢复这些信息。这样就省去了自己实现上下文切换的工作,减少了汇编的工作。

Sp指针指向线程独立的堆栈段。为什么需要这个字段呢?设想假如不要这个字段,线程切换时通过sigsetjmp/siglongjmp,确实能无差错地恢复上下文,但是这种上下文并不是数据,而仅仅是寄存器。也就是说,假如不为每个线程分配各自独立的栈空间,那么每个线程很可能破坏掉别的线程的栈空间,就算寄存器、堆栈指针都恢复了,同样的语句访问同样的内存,但是这些内存的值已经不同了。严重的情况下,比如破坏了函数的返回地址,根本不能运行。
简单举个例子,假设主线程main创建了线程1,调度程序调度到线程1执行。即调用线程1的回调函数。那么此时堆栈内存分布如下:

高地址
——————–
Main的相关信息
———————-
线程1函数的相关信息
———————
低地址

如果某一时刻,调度程序调度回main执行,改变的仅仅是寄存器的值,而内存并未改变。此时若main再建立一个线程2,调度到线程2执行的结果将是使线程2的相关信息覆盖掉线程1的相关信息。那么再调度回线程1的时候,线程1显然不能正确执行。

综上,必须为每个线程分配一个独立的堆栈空间,这个空间由malloc来分配。在新线程刚开始执行的时候,只需要简单地把ESP指针指向新分配的空间即可。这条指令需要使用汇编来实现。这是整个程序唯一需要使用汇编的地方。由于只需要一条指令,所以采用GCC内联汇编,关于内联汇编的语法,可以查阅GCC手册,这里不再做介绍了。
这里还有个烦人的陷阱,堆空间是由下往上增长的,栈是从上往下增长的。Malloc返回的指针指向的是堆空间的最低地址,所以必须把ESP的值改为malloc返回的指针加上其长度。
Sp还有一个重要作用是当线程结束时,free这段内存。

Callback和param字段:callback是一个函数指针,指向的函数无返回值(void),能接受一个void *参数,也就是param字段所保存的。
Next字段指向队列的下一个节点。

(3)调度相关
调度模块是一个函数。处理SIGALRM信号(或者其他时钟相关的信号)。SIGALRM信号是由alarm系统调用产生的。调度函数fthread_change捕捉这个信号,保存当前线程上下文并从队列中取出下一个线程,恢复其上下文,调度到这个线程执行。

5.杂项与bug

(1)栈空间不能分配的过小。关于一个简单的函数如printf调用的开销有多大,可以自己试一下,用gdb查看寄存器信息即可。
(2)信号处理函数中有“可再入”与“不可再入”的区别,比如malloc与printf都是不可再入的函数。使用在信号处理函数中可能有想象不到的错误。具体可以参阅《UNIX高级编程》
(3)Setjmp/longjmp并没有完全保存和恢复所有寄存器。比如eax就没有保存和恢复。在C中函数返回值是由EAX来存储的,因此假如在函数调用后EAX的值没来得及赋给一个变量的时候发生中断,调度到别的线程更改了EAX过后,这个返回值就被破坏。
(4)其余不再赘述,有兴趣的同学可与我发信交流:)

源代码就不贴了,Sun老师应该会公布:)

参考文献:
《操作系统概念》
《现代操作系统》
《UNIX高级编程》
《深入理解计算机系统》
《C专家编程》 《汇编语言编程艺术》