多线程编程导论

作者:Brian Masney

本文的目的是提供使用 POSIX 线程进行线程编程的基础知识,并非旨在成为线程编程的完整指南。它假定读者具有扎实的 C 编程基础。

线程有时被称为轻量级进程。线程将共享父进程的所有全局变量和文件描述符,这允许程序员在一个进程内轻松地分离多个任务。例如,您可以编写一个多线程 Web 服务器,并且可以为每个传入的连接请求生成一个线程。这将使线程内部的网络代码相对简单。与 fork 子进程来处理连接请求相比,使用多线程还将使用更少的系统资源。使用线程的另一个优点是它们将自动利用具有多个处理器的机器。

正如我之前提到的,线程与其父进程共享大部分资源,因此线程将比进程使用更少的资源。它共享所有内容,除了每个线程都有自己的程序计数器、堆栈和寄存器。由于每个线程都有自己的堆栈,因此局部变量不会在线程之间共享。这是正确的,因为静态变量存储在进程的堆中。但是,线程内部的静态变量将在线程之间共享。像 strtok 这样的函数在未经修改的情况下在线程内部无法正常工作。它们有可用于线程的可重入版本,其格式为 oldfunction_r。因此,strtok 的可重入版本将是 strtok_r

由于进程的所有线程共享相同的全局变量,因此访问全局变量的同步问题就出现了。例如,假设您有一个全局变量 X 和两个线程 A 和 B。假设线程 A 和 B 只是简单地递增 X 的值。当线程 A 开始执行时,它将 X 的值复制到寄存器中并递增它。在该线程有机会将值写回内存之前,该线程被挂起。下一个线程启动,读取第一个线程读取的相同 X 值,递增它并将其写回内存。然后,第一个线程完成执行并将其寄存器中的值写回内存。在两个线程完成后,X 的值递增了 1 而不是您期望的 2。

像这样的错误可能不会一直发生,因此可能很难追踪。在配备多个处理器的机器上,这变得更加严重,因为多个线程可以同时在不同的处理器上运行,每个线程都在修改相同的变量。解决此问题的方法是使用互斥锁 (互斥) 来确保只有一个线程正在访问代码的特定部分。当一个线程锁定互斥锁时,它对该部分代码具有独占访问权,直到它解锁互斥锁。如果第二个线程在另一个线程已锁定互斥锁时尝试锁定互斥锁,则第二个线程将阻塞,直到互斥锁被解锁并再次可用。

在最后一个示例中,您可以在递增变量 X 之前锁定互斥锁,然后在递增后解锁 X。因此,让我们回到最后一个示例。线程 A 将锁定互斥锁,将 X 的值加载到寄存器中,然后递增它。同样,在它有机会将其写回内存之前,线程 B 获取了 CPU 的控制权。它将尝试锁定互斥锁,但线程 A 已经控制了它,因此线程 B 将不得不等待。线程 A 再次获得 CPU,并将 X 的值从寄存器写回内存,然后释放互斥锁。线程 B 下次运行时并尝试锁定互斥锁时,它将能够锁定,因为它现在是空闲的。线程 B 将递增 X 并将其值从寄存器写回 X。现在,在两个线程都完成后,X 的值递增了 2,正如您所期望的那样。

现在让我们看看如何实际编写线程应用程序。您需要的第一个函数是 pthread_create。它具有以下原型

int pthread_create(pthread_t *tid,
   const pthread_attr_t *attr,
   void *(*func)(void *), void *arg)

第一个参数是存储其线程 ID 的变量。每个线程都有自己唯一的线程 ID。第二个参数包含描述线程的属性。您通常只需传递一个 NULL 指针。第三个参数是指向您要作为线程运行的函数的指针。最后一个参数是指向您要传递给函数的数据的指针。如果您想从线程退出,可以使用 pthread_exit 函数。它具有以下语法

void pthread_exit(void *status)
这将返回一个稍后可以检索的指针(见下文)。您不能返回指向该线程本地的指针,因为该数据将在线程退出时被销毁。

线程函数原型表明线程函数返回一个 void * 指针。您的应用程序可以使用 pthread_join 函数来查看线程返回的值。pthread_join 函数具有以下语法

int pthread_join(pthread_t tid, void **status)

第一个参数是线程 ID。第二个参数是指向您的线程函数返回的数据的指针。系统会跟踪来自您的线程的返回值,直到您使用 pthread_join 检索它们。如果您不关心返回值,您可以调用 pthread_detach 函数,并将其线程 ID 作为唯一参数,以告知系统丢弃返回值。您的线程函数可以使用 pthread_self 函数来返回其线程 ID。如果您不需要返回值,您可以在您的线程函数内部调用 pthread_detach(pthread_self())

回到互斥锁,以下两个函数可供我们使用:pthread_mutex_lockpthread_mutex_unlock。它们具有以下原型

int pthread_mutex_lock(pthread_mutex_t *mptr)
int pthread_mutex_unlock(pthread_mutex_t *mtr)

对于静态分配的变量,您必须首先将互斥锁变量初始化为常量 PTHREAD_MUTEX_INITIALIZER。对于动态分配的变量,您可以使用 pthread_mutex_init 函数来初始化互斥锁变量。它具有以下原型

int pthread_mutex_init(pthread_mutex_t *mutex,
   const pthread_mutexattr_t *mutexattr)
现在我们可以查看实际代码,如清单 1 所示。我已经注释了代码以帮助读者理解正在做什么。我也使程序非常基础。它没有做任何真正有用的事情,但应该有助于说明线程的概念。此程序所做的只是启动 10 个线程,每个线程都递增 X,直到 X 达到 4,000。您可以删除 pthread_mutex_lock 和 unlock 调用,以进一步说明互斥锁的用途。

清单 1. 示例程序

关于此程序,还需要解释一些事项。您系统上的线程可能按照它们创建的顺序运行,并且它们可能在下一个线程运行之前运行完成。无法保证线程的运行顺序,也无法保证线程将在不中断的情况下运行完成。如果您将“实际工作”放入线程函数内部,您将看到调度器在线程之间交换。您可能还会注意到,如果您取出互斥锁和解锁,X 的值可能是预期的值。这一切都取决于线程何时被挂起和恢复。一个线程应用程序最初可能看起来运行良好,但当它在具有许多其他东西同时运行的机器上运行时,程序可能会崩溃。查找这些类型的问题对于应用程序员来说可能非常麻烦;这就是为什么程序员必须确保共享变量受到互斥锁保护的原因。

全局变量 errno 的值怎么样?假设我们有两个线程 A 和 B。它们已经在运行,并且处于线程内部的不同点。线程 A 调用一个将设置 errno 值的函数。然后,在线程 B 内部,它将唤醒并检查 errno 的值。这不是它期望的值,因为它刚刚从线程 A 检索了 errno 的值。为了解决这个问题,我们必须定义 _REENTRANT。这将更改 errno 的行为,使其指向每个线程的 errno 位置。这对应用程序员来说将是透明的。_REENTRANT 宏还将更改某些标准 C 函数的行为。

要获得关于线程的更多信息,请访问 LinuxThreads 主页,网址为 http://pauillac.inria.fr/~xleroy/linuxthreads/。此页面包含指向许多示例和教程的链接。它还提供了一个链接,您可以在其中下载线程库(如果您尚未安装)。只有当您拥有基于 libc5 的机器时才需要下载;如果您的发行版是基于 glibc6 的,则 LinuxThreads 应该已经安装在您的计算机上。我编写的线程应用程序 gFTP 的源代码可以从我的网站 http://www.newwave.net/~masneyb/ 下载。此代码利用了本文中提到的所有概念。

资源

Brian Masney 目前是西弗吉尼亚州雅典市康科德学院的学生。他还在当地一家电脑商店担任电脑技术员。在业余时间,他喜欢户外活动和编程。可以通过 masneyb@newwave.net 与他联系。

加载 Disqus 评论