在UNIX主机上,线程常常又被称为“轻量级进程”,这种称呼很简单同时也便于理解,事实上,UNIX线程是从进程演变而来的。与进程相比,线程相当小,创建线程引起的CPU开销也相对较小。不仅如此,由于线程可以共享内存资源,而不像进程那样拥有独立的内存空间,所以使用线程也很节省内存。以后的几篇文章,将重点讲述POSIX 线程标准最常用的部分(主要基于其在DEC OSF/1 OS, V3.0上的实现)。
1. Hello World
创建进程所用的函数是pthread_create()。它的四个参数包括了:一个线程(pthread_t)变量的指针、一个线程属性(pthread_attr_t)变量的指针、线程启动时所要运行的函数指针以及传递给该函数的一个参数(void *)。
pthread_attr_t a_thread_attribute;
void * thread_function( void * argument);
char * some_argument;
pthread_create( & a_thread, a_thread_attribute, thread_function, ( void * )some_argument);
很多时候,线程属性变量仅仅指定线程使用的最小栈。其实它有更丰富的含义,但现在的情况是,大多数应用程序创建新线程时值不过传递了一个 PTHREAD_ATTR_DEFAULT,有时甚至只是NULL。用pthread_create()创建出的新线程,从指定的函数入口开始执行,这与创建进程不同:所有的进程都具有相同的执行序列。这样设计的原因很简单:如果所有的进程都从同一进程空间的同一处开始执行,那么就会有多个进程对相同的共享资源执行相同的指令。
现在我们已经知道如何创建新线程,就让我们来开始第一个多线程程序:用多个线程在屏幕上输出“Hello World”。为了显示线程的作用,我们将用到两个线程:一个用来输出“Hello”、另一个用来输出“World”。为此,我们首先需要一个用于屏幕输出的函数,新线程将从此函数开始执行。此外,我们还需要两个线程(pthread_t)变量,用来创建新线程。当然,我们需要在 pthread_create()的参数中指明每个新线程应该输出的字符串。请看以下代码:
main()
... {
pthread_t thread1, thread2;
char *message1 = "Hello";
char *message2 = "World";
pthread_create( &thread1, pthread_attr_default,
print_message_function, (void*) message1);
pthread_create(&thread2, pthread_attr_default,
print_message_function, (void*) message2);
exit(0);
}
void * print_message_function( void * ptr )
... {
char *message;
message = (char *) ptr;
printf("%s ", message);
}
这里需要注意的是print_message_function()函数的原型,以及创建新线程时对参数类型的转换。程序首先创建第一个新线程并将 “Hello”作为参数传递,接着创建了另一个线程并传递“World”作为起始参数。我们希望第一个线程从 printf_message_function()开始执行,在输出“Hello”后结束,接着第二个线程在输出“World”之后也同样地结束。这样的过程看起来似乎很合理,然而其中有两处严重缺陷。
首先,不同的线程是并行运行的,并无先后次序。因此我们无法保证第一个新线程在第二线程之前输出字符串。其结果是,屏幕输出可能是“Hello World”,也可能是“World Hello”。其次,与上述原因类似,父线程(姑且如此称呼)有可能在两个子线程输出之前就执行了exit(0),这将导致整个进程结束——当然两个子进程也就因此而结束了。其后果是屏幕上可能根本没有输出。为了解决第二个问题,我们可以用pthread_exit()来代替exit(),这样两个子进程就不会结束(因为该函数不会终止整个进程的运行)。
目前我们的小程序有两个竞争条件,现在让我们试着用比较笨的办法来解决它们。首先,为了让两个子线程按照我们需要的顺序运行,我们在创建第二个线程之前插入一个延迟。接着,为了保证在子线程结束之前父线程不退出,我们在父线程的尾部也插入一个延迟。请看下面的代码:
main()
... {
pthread_t thread1, thread2;
char *message1 = "Hello";
char *message2 = "World";
pthread_create( &thread1, pthread_attr_default,
print_message_function, (void *) message1);
sleep(10);
pthread_create(&thread2, pthread_attr_default,
print_message_function, (void *) message2);
sleep(10);
exit(0);
}
void * print_message_function( void * ptr )
... {
char *message;
message = (char *) ptr;
printf("%s", message);
pthread_exit(0);
}
令人遗憾的是,以上代码仍然不能达到我们的目的。利用延迟来进行线程同步是很不可靠的。我们目前遇到的同步问题本质上与分布式程序设计中的同步问题相同:我们永远无法确知某一个线程将会在何时结束。
以上代码的缺陷不只是不可靠,事实上sleep()函数执行时,整个进程都在睡觉而不仅仅是父线程,这一点和exit()很像。当sleep()返回时,我们的程序仍然面对着相同的条件竞争。我们的新代码不仅没有解决竞争问题,反而让我们多花了20秒来等待程序结束。顺便应该指出,如果想要对某一线程进行延迟,应该调用pthread_delay_np()函数(np意指non portable,不可移植),如下:
delay.tv_sec = 2 ;
delay.tv_nsec = 0 ;
pthread_delay_np( & delay );
2. 线程同步
POSIX提供了两种用于线程同步的原语,这两种操作分别是互斥以及条件变量。互斥是一种简单的进行锁定的原语,其主要作用是控制对共享资源的访问,防止冲突。关于多线程编程,有一点值得大家注意,那就是整个程序的地址空间有所有的线程共享。其结果是几乎所有的资源都可以被共享——比如全局变量、文件描述符等。另一方面,在每个线程的入口函数(由pthread_create调用)内,以及由该函数调用的其他函数内,我们都会定义一些私有的局部变量。在多线程程序中,全局变量与局部变量总是被混合使用,要想使多线程程序顺利的运行,各线程对共享资源的访问必须得到控制。
以下是一个生产者/消费者程序。生产者与消费者对共享缓冲区的访问由互斥进行控制。
void * reader_function( void * );
void * writer_function( void * );
char buffer;
int buffer_has_item = 0 ;
pthread_mutex_t mutex;
struct timespec delay;
main()
... {
pthread_t reader;
delay.tv_sec = 2;
delay.tv_nsec = 0;
pthread_mutex_init(&mutex, pthread_mutexattr_default);
pthread_create( &reader, pthread_attr_default, reader_function,
NULL);
writer_function();
}
void * writer_function( void * )
... {
while(1)
...{
pthread_mutex_lock( &mutex );
if ( buffer_has_item == 0 )
...{
buffer = make_new_item();
buffer_has_item = 1;
}
pthread_mutex_unlock( &mutex );
pthread_delay_np( &delay );
}
}
void * reader_function( void * )
... {
while(1)
...{
pthread_mutex_lock( &mutex );
if ( buffer_has_item == 1)
...{
consume_item( buffer );
buffer_has_item = 0;
}
pthread_mutex_unlock( &mutex );
pthread_delay_np( &delay );
}
}
上边这个简单的例子程序中,共享缓冲区只能保存一个共享数据项。因此该缓冲区只有两个状态:“有”/“无”。生产者在向缓冲区写入数据前,首先会将互斥上锁,如果该互斥已被锁定,则生产者将阻塞直到互斥被解锁。生产者锁定了互斥以后,将会检查缓冲区是否为空(通过标志变量 buffer_has_item)。如果缓冲区没有数据,生产者就会产生新数据项放入缓冲区,并设置标志变量以使得消费者可以知道是否能进行消费。接下来生产者解除对互斥的锁定并等待,这样消费者应该有充足的时间来访问缓冲区。
消费者采取了相似的过程来访问缓冲区。它首先锁定互斥,检查标志变量,如果可能则消费掉仅有的数据项。接着消费者解锁互斥并等待一小会儿好让生产者有时间写入新的数据项。
上例中,生产者和消费者将会持续不断的运行,不断的生产、消费。事实上,在通常的程序中,如果确定不再使用某个互斥,则应该用 pthread_mutex_destroy(&mutex)将其摧毁。顺便提一句,在使用某个互斥之前,应该使用 pthread_mutex_init()将其初始化。在我们的例子中,初始化时使用了两个参数,第一个用来指定被初始化的互斥,第二个则是该互斥的属性。(在DEC OSF/1上,互斥的属性没有实际意义,通常使用(THREAD_MUTEXATTR_DEFAULT)。
对互斥的正确使用可以有效地减少竞争条件。其实互斥本身是非常简单的,只有两个状态:锁定、未锁定。它能实现的功能也是有限的。POSIX还提供了条件变量这一有力工具来补充互斥的不足。使用条件变量,一个线程可以在已经锁定互斥的情况下被阻塞并等待唤醒信号,而其他线程仍能访问被锁定的共享资源。当另外的某一个线程发出信号后,被阻塞的线程将被唤醒并依然可以访问阻塞前自己锁定的共享资源。由此,互斥和条件变量的联合使用可以帮助我们避免循环死锁的情况出现。我们利用互斥和条件变量设计了一个仅有单一整数信号灯的库。