作者:Evan Jones
翻译:杨硕
在程序在运行的时候,通常都是只有一条主线的,有些时候,这是效率不高的。有很多程序必须要能同时做很多工作。比如说,GUI程序必须在处理程序逻辑的同时重画界面,响应用户输入。Web服务器必须同时向成百上千的客户发送信息。线程可以解决这些难题。所有的现代操作系统都提供线程库,比如标准Posix线程。特别是,我对线程的工作原理十分好奇,所以在学习了一些C和Linux内核的知识后,我开始建立自己的线程库。
多任务
在传统的操作系统中,提供了实现多任务的两种工具,一种是进程,一种是线程。
进程
每个程序都是独立的进程。操作系统在所有进程间分配资源,并且为了防止一个进程修改其他进程的资源,进程间是彼此完全独立的。但是,多个进程配合完成一项任务也是很常见的。比如说,图1展示了我的Linux系统下同时进行的进程,其中有4个进程执行mozilla-bin。在这种情况下,进程间需要相互通信,因此操作系统提供了进程间通信(IPC)的工具,比如有信号,共享内存和管道。
Process ID Command
1 init
142 /sbin/syslogd
183 /usr/bin/X11/X
266 esd
270 sawfish
275 panel
277 gmc
279 gnome-terminal
291 bash
319 /usr/bin/mozilla-bin
341 /usr/bin/scite
344 /usr/bin/mozilla-bin
345 /usr/bin/mozilla-bin
346 /usr/bin/mozilla-bin
2568 ps
图1 Linux系统上的进程列表
线程
进程间通信是非常简单和便于使用的,但是,如果有很多进程需要共享彼此的资源,模型就会变得十分复杂。线程提供了一种解决这类问题的简单和高效的方法。一个进程可以含有多个线程,除了环境上下文(堆栈和CPU寄存器)以外,所有的资源在线程间都是共享的。因此,如果一个线程修改了某个共享资源,则这种改变对于其它线程也是可见的。线程的缺点就是引入了一个新问题,即要避免两个线程同时访问某一个共享资源。
内核线程
现代操作系统在内核级上支持线程。这意味着线程直接由操作系统的调度器进行调度,这可以带来大幅度的性能提升。内核线程的缺点是线程切换的花销比较大,因此并发多个线程可能比单线程的效率要低,因为线程切换占用了一定的时间。这也是单线程服务器,如thttpd,要比多线程服务器,如Apache性能好的原因。JAWS research project对用不同多任务方法实现的web服务器的性能作了很深入的分析。
用户线程
线程也可以在用户级上实现,这意味着进行线程调度是由程序自身或线程库来完成的。如果线程在用户级上实现,同样也会有很大的线程切换开销,但是,这种开销要比在内核级的线程切换小很多。有些时候,用户线程被称为fiber(纤程),暗示着它要比内核线程“轻”。一个简单的线程库
为了完全玩转这些概念,libfiber诞生了。它实现了用户线程和内核线程。该线程库仅仅实现了线程的创建,销毁及调度。它只能被用作教学,因为还有很多信号和同步的问题没有解决。在编写真实的程序时,可以使用内核线程库pthreads或用户线程库GNU Portable Threads (Pth)。
Libfiber 接口
void initFibers();
初始化线程库的内部数据结构。必须在使用其他函数前调用。
int spawnFiber( void (*func)(void) );
创建线程,该线程将会执行给定的函数。成功返回零,错误返回非零。
void fiberYield();
主动放弃CPU。在内核线程实现中,只是简单的调用sched_yield。
void waitForAllFibers();
等待所有线程退出,释放资源。如果在用户线程中调用,只有所有其它线程全部返回该函数才会返回;如果在内核线程中调用,它会立即返回。成功返回零,错误返回非零。
图2显示的是用该线程库编写的样例程序。它创建了三个线程,分别执行不同的函数,等待所有线程退出后返回。
int main()
{
// Initialize the fiber library
initFibers();
// Go fibers!
spawnFiber( &fiber1 );
spawnFiber( &fibonacchi );
spawnFiber( &squares );
// Since these are not preemptive, we must allow them to run
waitForAllFibers();
// The program quits
return 0;
}
图2 使用线程库的样例程序
在Linux上实现内核线程
Linux使用一个特殊的方法实现线程。在Linux上,进程和线程在本质上是一样的,它们都被认为是任务。唯一的区别是现成共享内存空间,文件描述符和信号处理程序(通常情况下)。在Linux Kernel Mailing List上,Linus Torvalds发过一个经典的帖子来阐述这种实现方式的优点。
在Linux上,内核线程可以通过系统调用clone来创建。这个系统调用跟fork相似,都是创建一个任务。只不过clone可以指定共享哪些资源。在创建线程时,我们可以共享尽可能多的资源:内存空间,文件描述符和信号处理函数。当线程退出,发出SIGCHLD信号时,wait将会返回。
第一个挑战是为线程分配栈空间。在libfier中使用的是最简单的方法,即使用malloc在堆中分配栈空间。这意味着必须要估计一个栈的最大值。如果栈在使用时超出了最大值,会引起内存冲突。在bb_threads中,是使用mprotect在栈底创建一个barrier,这样在栈溢出的时候会引发段错误。最好的方法是Linux pthreads的实现,是使用mmap来分配内存,这样的话,内存随需随分配,如果系统不能分配另外的内存,将会产生segmentation violation。
传递给clone的栈指针必须指向栈顶,因为在多数处理器中,栈是向下生长的。为了避免内存泄露,线程退出时必须释放栈空间。Libfiber库使用wait等待线程退出,然后使用free释放栈空间。图3显示了创建线程的例子。请通过libfiber-clone.c查看完整的实现。如果想了解Linux pthread如何实现创建线程,请看"The Fibers of Threads"。
#include
#include
#include
#include
#include
#include
// 64kB stack
#define FIBER_STACK 1024*64
// The child thread will execute this function
int threadFunction( void* argument )
{
printf( "child thread exiting\n" );
return 0;
}
int main()
{
void* stack;
pid_t pid;
// Allocate the stack
stack = malloc( FIBER_STACK );
if ( stack == 0 )
{
perror( "malloc: could not allocate stack" );
exit( 1 );
}
printf( "Creating child thread\n" );
// Call the clone system call to create the child thread
pid = clone( &threadFunction, (char*) stack + FIBER_STACK,
SIGCHLD | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_VM, 0 );
if ( pid == -1 )
{
perror( "clone" );
exit( 2 );
}
// Wait for the child thread to exit
pid = waitpid( pid, 0, 0 );
if ( pid == -1 )
{
perror( "waitpid" );
exit( 3 );
}
// Free the stack
free( stack );
printf( "Child thread returned and stack freed.\n" );
return 0;
}
图3 用clone实现线程
使用makecontext实现用户线程
现代Unix系统都在ucontext.h中提供用于上下文切换的函数,这些函数有getcontext, setcontext,swapcontext 和makecontext。其中,getcontext用于保存当前上下文,setcontext用于切换上下文,swapcontext会保存当前上下文并切换到另一个上下文,makecontext创建一个新的上下文。实现用户线程的过程是:我们首先调用getcontext获得当前上下文,然后修改ucontext_t指定新的上下文。同样的,我们需要开辟栈空间,但是这次实现的线程库要涉及栈生长的方向。然后我们调用makecontext切换上下文,并指定用户线程中要执行的函数。
在这种实现中还有一个挑战,即一个线程必须可以主动让出CPU给其它线程。swapcontext函数可以完成这个任务,图4展示了一个这种实现的样例程序,child线程和parent线程不断切换以达到多线程的效果。在libfiber-uc.c文件中可以看到完整的实现。
#include
#include
#include
// 64kB stack
#define FIBER_STACK 1024*64
ucontext_t child, parent;
// The child thread will execute this function
void threadFunction()
{
printf( "Child fiber yielding to parent" );
swapcontext( &child, &parent );
printf( "Child thread exiting\n" );
swapcontext( &child, &parent );
}
int main()
{
// Get the current execution context
getcontext( &child );
// Modify the context to a new stack
child.uc_link = 0;
child.uc_stack.ss_sp = malloc( FIBER_STACK );
child.uc_stack.ss_size = FIBER_STACK;
child.uc_stack.ss_flags = 0;
if ( child.uc_stack.ss_sp == 0 )
{
perror( "malloc: Could not allocate stack" );
exit( 1 );
}
// Create the new context
printf( "Creating child fiber\n" );
makecontext( &child, &threadFunction, 0 );
// Execute the child context
printf( "Switching to child fiber\n" );
swapcontext( &parent, &child );
printf( "Switching to child fiber again\n" );
swapcontext( &parent, &child );
// Free the stack
free( child.uc_stack.ss_sp );
printf( "Child fiber returned and stack freed\n" );
return 0;
}
图4用makecontext实现线程
结论
实现一个简单的线程库libfiber可以让我们更好的理解线程库是如何实现线程的,当我们在分析不同多线程工具的时候,上面的这些经验就会派上用场。本线程库是在Linux开发并测试的,但是真正的线程库应该是可以移植到Unix平台上的,从这点上来说,本线程库实现的内核线程是Linux专有的,因为它是利用clone系统调用来实现的。

