内容


pthreads 的基本用法

介绍 POSIX 线程

Comments

除了 Anne McCaffrey 的系列小说 Dragonriders of Pern 之外,“线程”是令程序员谈虎色变的词儿。线程有时称为轻型进程,是与大型复杂的项目相关的。调用库函数时经常会遇到一些“线程不安全”的可怕警告。但这些线程究竟是什么?使用它们能做什么?使用线程有什么风险?

本文通过一个简单的线程应用程序来介绍线程。使用的线程模型是 POSIX 线程接口,通常称为 pthreads。本文例子基于 SuSE Linux 8.2 平台。所有代码都在 SuSE Linux 8.2 上和 NetBSD-current 的最新构建上测试通过。

什么是线程?

线程和进程十分相似,不同的只是线程比进程小。首先,线程采用了多个线程可共享资源的设计思想;例如,它们的操作大部分都是在同一地址空间进行的。其次,从一个线程切换到另一线程所花费的代价比进程低。再次,进程本身的信息在内存中占用的空间比线程大,因此线程更能允分地利用内存。

线程之间通常需要进行交互,因此就存在使用 IPC 进行多进程通信的问题。本文中对于多进程通信问题不做过多的讨论,因为 POXIS 线程 API 提供了处理诸如死锁和竞态条件这类问题的工具。本文主要讨论特定于多线程编程的问题和解决方案,一般的多道程序设计问题留待以后讨论。

线程程序有时会出现在多进程和 IPC 程序设计中不常出现的一些问题。例如,如果两个线程同时调用一个函数,如 asctime() (它使用一个静态的数据区),会产生不可思议的结果。这是“线程安全”要考虑的问题。

一个简单程序

本文使用的示例程序是一个投骰子程序。人们在玩角色扮演游戏或者战争游戏时,经常会把很多的时间花费在掷骰子上。一个可以通过网络存取的掷骰子程序在许多方面适合作为示例程序。其中程序代码非常简单是最重要的原因,这样对程序逻辑的理解就不会影响对线程的理解。

最使人分心的是网络部分,为了尽量简化我们的学习过程,这部分代码全都封装在一些子程序中。第一个子程序 socket_setup() ,它返回一个准备接受连接的套接字。第二个子程序 get_sockets() ,以这个套接字为参数,接受连接,并创建一个 struct sockets 对象。

struct sockets 对象只是一个对输入/输出端口的抽象描述。一个 FILE * 用于输入,另一个用于输出,还有一个标志用来提醒我们以后正确地关闭套接字。

您可以从 参考资料以 tar 压缩格式下载该程序的不同版本,并在单独的 shell 中查看或运行这些程序,或者在单独的浏览器窗口中查看。第一个版本是 dthread1.c

让我们仔细分析一下这个程序 dthread1 。它具有几个选项。第一个选项(当前未用,为功能扩展保留)是一个调试标志,用选项 -d 表示。第二个选项是 -s ,它决定程序是否在控制台环境运行。如果没有指定 -s 选项,程序将会监听连接并通过连接进行会话。第三个选项 -t 确定是否运行多线程。如果没有指定 -t 选项,程序将只处理一个连接,处理完成后就退出。最后一个是 -S 选项,它使程序在每两次投掷间停顿一秒。使用这个选项只是为了有趣,因为这样我们就容易看到多个连接间的交替过程。

我们来好好研究一下这个程序。要该程序一运行就连接到它,请尝试 telnet localhost 6173 。如果以前您对这类掷骰子的游戏不熟悉,那么请 2d6 开始 。所支持的一般语法是“NdX”,意思是投 N 个骰子,每个骰子可以投出的范围从 1 到 X(从代码中可以看出,程序实际是投一个具有 X 面的骰子 N 次)。

这里看到了线程程序最简单的形式。有一点需要注意的是:每个线程都只是处理自己独特的数据。(这并不完全正确;如果您发现了这个错误,说明您很有观察力)。这样可以避免各个线程相互交叠。为此, pthread_create() 将接受一个 void * 类型的参数,这个参数会被传到线程开始执行的那个函数中去。这样允许您创建一个任意复杂的数据结构,并将它作为一个指针传送给需要在这个数据结构上进行操作的线程。当其地址传入给 pthread_create() 的函数结束后,该线程也就结束了,而其他线程会继续运行。

对于多线程程序来说一个显而易见的问题是如何干净彻底地终止该程序(同样,我们也可以简单地用 Ctrl-C 来终止它)。对于单线程程序来说,我们很容易知道是如何终止的:当用户退出时程序就退出了。但是对于连接了四个用户的程序,应该何时退出呢?答案明显是“在最后一个用户退出后”。但是如何确定最后一个用户已经退出呢?一种解决方法是增加一个变量,每创建一个新的线程该变量就加 1,每终止一个线程该变量就减 1,当该变量再次为零时,就关闭整个进程。

这个方法听起来不错,然而它同时也存在会使程序崩溃的危险。

竞态条件和互斥

可以想像一下,如果在一个线程正在创建的同时另一线程正在退出,那么会发生什么情况呢?如果线程调度器正巧在它们之间切换,程序会莫名其妙地关闭。线程 1 正在执行 i = i + 1; 这样的代码,线程 2 则在执行 i = i - 1; 这样的代码。为了讨论的方便,假定变量 i 的初始值是 2。

 线程 1:取出 i 的值(2)。
          
线程 1: i 加 1 (结果是 3)。
线程 2:取出 i 的值(2)。
线程 2:将 i 的值减 1 (结果是 1)。
线程 2:将结果保存到 i ( i=1)。
线程 1:将结果保存到 i ( i=3)。

啊呀!

我们这里遇到的情况叫做竞态条件(race condition),是一种出错概率非常小的条件,意味着您只有非常快速或者非常运气不好才会遇到这种情况。竞态条件在几百万次运行中也很少遇到一次,所以很难调试出来。

我们需要采取一些方法避免线程 1 和线程 2 出现上述情况;这些方法要保证线程 1 “在完成对 i 的操作前不允许其他线程对 i 操作”。可以进行许多有趣的尝试去发现一个合适的方法;这个方法要保证两个线程不会冲突。您可以利用现有的机制自己编制代码去尝试,从中可以体会到更多的乐趣。

下一个要理解的概念就是 互斥(mutex)。互斥量(mutex 是 MUTual EXclusion 的缩写)是避免线程间相互交叠的一种方法。可以把它想像成一个惟一的物体,必须把它收藏好,但是只有别人都不占有它时您才可以占有它,在您主动放弃它之前也没有人可以占有它。占有这个惟一物体的过程就叫做锁定或者获得互斥量。不同的人学到的对这这件事的叫法不同,所以当您和别人谈到这件事时别人可能会用不同的词来表达。POSIX 线程接口沿用相当一致的术语,所以称为“锁定”。

创建和使用互斥量的过程比仅仅是开始一个线程的过程要稍微复杂一些。互斥量对象必须先被声明;声明后还必须初始化。做完这些之后,才可以被加锁和解锁。

互斥代码:第一个例子

在浏览器(或者展开的 pth 目录)中查看 dthread2.c

为了方便,对 pthread_create() 的调用被单独放到一个新的称为 spawn() 的例程中。这样做使增加互斥代码时只需要在一个子程序中进行改动。这个例程做了一些新的工作;它锁定一个叫做 count_mutex 的互斥量。之后它创建一个新的线程同时增量 threadcount ;在完成之后,它解锁互斥量。当一个线程准备终止时,它再次锁定互斥量,减量 threadcount ,然后解锁互斥量。如果互斥量 threadcount 的值减小到了零,我们知道这时已经没有线程在运行了,该退出程序了。然后这种说法并不完全正确;这时仍然有一个线程在运行,这个线程是进程初始运行时建立的线程。

您可能会注意到对一个叫做 pthread_mutex_init() 的函数的调用。这个函数对互斥量运行环境进行初始化,这个过程对互斥量正常工作是必需的。当不再使用互斥量时,可以调用 pthread_mutex_destroy() 来释放在初始化过程中分配的资源。有些实现不分配或释放任何资源;只是使用了 API。如果您不进行这些调用,总有您最不期望出现的那一刻,一个生产系统将会使用一种新的依赖于这些调用的实现 —— 您会在临晨 3 点钟发现它带来的问题。

spawn() 中将锁定互斥量的代码紧跟在改变 threadcount 的后面似乎是合理的。听起来也不错,不幸的是,这样做实际会引入一个令人气馁的 bug。这种程序会非常频繁地输出一个提示,然后挂起不动。听起来有些令人不解?记住,一旦 pthread_create() 被调用,新的线程就开始执行了。所以事件发生的顺序看起来是以下面的顺利进行的:

主线程:调用 pthread_create()。
新的子线程:输出提示信息。
旧的子线程:退出,减量 threadcount 到 0。
主线程:锁定互斥量并增量 threadcount。

当然,按这个顺序最后一步永远不会执行。

如果您将调用 pthread_create() 前面改变 threadcount 值的代码去掉,那么互斥量代码中间就只剩下减小计数值的语句了。示例程序不是按照这样写的,这样做只是为了增加例子的趣味性。

解开死锁

这篇文章的前面曾经提到过原始程序中的一个微妙的潜在的竞态条件。现在,谜底已经揭穿。这个不易发现的竞态条件是 rand() 存在内部状态。如果在两个线程交叠时调用 rand() ,它就可能返回一个错误的随机数。对这个程序来说,这可能不是什么大问题,但对于一个以随机数的再现性为基础的正规的模拟程序来说,这可就是一个大问题了。

所以,让我们更进一步,在产生随机数的地方加一个互斥量。这样,我们就容易地解决了这个竞态条件问题。

浏览 dthread3.c,或者在 pth 目录下打开这个文件。

不幸的是,这里仍旧存在另一个潜在的问题,叫做死锁。当两个(或以上)线程相互等待别的线程时就会出现死锁。想像这儿有两个互斥量,我们分别称它们为 count_mutexrand_mutex 。现在,有两个线程需要使用这两个互斥量。线程 1 的活动如下:

mutex_lock(&count_mutex);
          
mutex_lock(&rand_mutex);
mutex_unlock(&rand_mutex);
mutex_unlock(&count_mutex);

而线程 2 却是以另外的顺序执行这些语句:

mutex_lock(&rand_mutex);
          
mutex_lock(&count_mutex);
mutex_unlock(&count_mutex);
mutex_unlock(&rand_mutex);

这时就会发生死锁等待。如果这两个线程以上面的顺序同时开始执行,它们同时开始执行锁定:

Thread 1: mutex_lock(&count_mutex);
          
Thread 2: mutex_lock(&rand_mutex);

接下来会发生什么?如果线程 1 要运行,它就要锁定 rand_mutex ,可是这个互斥量已经被线程 2 阻塞了。如果线程 2 要运行,它就要锁定 count_mutex ,而这个互斥量已经被线程 1 占有了。这种情况可以引用一则杜撰的 Texas 法规来形容:“当两列火车在十字路口相遇时,它们都会停止前进,都等待对方开走后才能前进”。

像这样的问题,一个简单的解决办法是保证以相同的顺序获得互斥量。相似地,使每列火车安全通过的一个简单办法是始终对火车保持控制。在实际程序中,使引起死锁的调用都整齐地排队进行不太可能,即使在我们的简单的示例程序中(如果正确安排调用的顺序,完全可以避免死锁),对互斥量的调用也不是完全相邻的。

现在,将注意力转到下一个例子, dthread4

这个版本的程序演示了一个产生死锁的一般来源:程序员的错误。

这个程序允许在一行上有多个规范,同时也限制骰子是角色游戏中常见的普通的多面骰子。这个坏程序的开发者起初的想法是好的,即只在真正需要使用之前才锁定这些互斥量,但是他却直到运行结束才解锁。因此,如果用户输入“2d6 2d8”,程序会锁定六面的骰子,投掷两次,然后锁定八面的骰子,投掷两次。只有等到所有的投掷过程全部结束,才将所有骰子解锁。

与以前版本不同的是,这个版本很容易进入死锁状态。如果您愿意的话,设想一下两个用户同时请求掷骰子,一个请求“2d6 2d8”,另一个请求“2d8 2d6”。会发生什么情况呢?

 线程 1:锁定六面的骰子;投掷。
          
线程 2:锁定八面的骰子;投掷。
线程 1:试图锁定八面的骰子。
线程 2:试图锁定六面的骰子。

“聪明的”解决方案实际上是根本没有解决方案。如果投骰子的人只要一投完就马上就释放他拥有的骰子,就不会出现这个问题。

从中我们得到的第一个教训是,您可以深究模拟情况;坦白地说,锁定单个骰子的存取愚蠢的。然而,第二个教训是,不可能看到代码里面的死锁。应该锁定哪些互斥量取决于仅在运行时可用的数据,这是问题的关键所在。

如果您想实际体会一下这个 bug,试着使用 -S 选项,这样程序运行时您就有足够的时间在不同的终端间切换并且进行观察。现在,您打算怎么改正?为了讨论的方便,假设有必要锁定单个的骰子。您是怎么做的呢? dthread5.c 中是一个天真的解决方案。到了这儿,您就可以回到前面给每个骰子加一个随机数产生器。那些好的游戏者明白骰子投出来的结果好坏各半,您不会浪费掷出来的好点数,对吧?

死锁也会发生在单独的线程中。缺省使用的互斥量是“快速”的,这种互斥量有一个很大的优点,如果您试图去锁定一个您已经锁定的互斥量,这时就会产生死锁。如果可能,在设计程序时决不要锁定一个已经锁定的互斥量。另外,您还可以使用“递归”类型的互斥量,这种互斥量允许对同一个互斥量锁定多次。还有一种互斥量是用于检测一般错误的,如对一个已经解锁的互斥量再次解锁。注意,递归互斥量不能帮您解决程序中实际存在的锁定 bug。下面的代码段是从早一些版本的 dthread3.c 中摘出来的:

清单 1. 摘自 dthread3.c 的代码段,其中包含 bug
   int
   roll_die(int n) {
      pthread_mutex_lock(&rand_mutex);
      return rand() % n + 1;
      pthread_mutex_unlock(&rand_mutex);
   }

看看您能不能比我更快发现 bug(我用了大约 5 分钟)。为了守信,试着在早上 4:30 开始查错。您可以在本文结尾处的 侧栏中找到答案。

对死锁的全面讨论超出了本文的范围,但现在您知道应该去查什么资料了,而且可以在 参考资料一节的参考资料列表中找到更多的关于互斥量和死锁的资料。

条件变量

条件变量是另一种有趣的变量,条件变量允许在条件不满足时阻塞线程,等条件为真时再唤醒该线程。函数 pthread_cond_wait() 主要就是用于阻塞线程的,它有两个参数;第一个是一个指向条件变量的指针,第二个是一个锁定了的互斥量。条件变量同互斥量一样必须使用 API 调用对其初始化,这个 API 调用就是 pthread_cond_init() 。当不再使用条件变量时,应该调用 pthread_cond_destroy() 释放它在初始化时分配的资源。对于互斥量来说,这些调用在有些实现中可能并不做什么,但是您也应该调用它们。

pthread_cond_wait() 被调用后,它解锁互斥量并停止线程的执行。在别的线程唤醒它之前它会一直保持暂停状态。这些操作是“原子操作”;它们总是一起执行,没有任何其他线程在它们之间执行。它们执行完之后,其他线程才开始运行。如果另一个线程对一个条件变量调用 pthread_cond_signal() ,那么那个等待这个条件而被阻塞的线程就会被唤醒。如果另一个线程对这个条件变量调用 pthread_cond_broadcast() ,那么所有等待这个条件而被阻塞的线程都会被唤醒。

最后,当一个线程从调用 pthread_cond_wait() 而被唤醒时,要做的第一件事就是重新锁定它在最初调用时解锁的那个互斥量。这个操作要消耗一定的时间,实际上,这个时间太长了,可能在进行这项操作的同时,线程所等待的条件变量的值在这期间已经改变了。比如,一个正在等待货物被加入到链表中的线程,当它被唤醒时可能发现链表是空的。在有些实现中,线程可能偶尔在没有信号送到条件变量的情况下醒过来。线程编程不一定总是精确的科学,所以在编程时要始终注意这一点。

更多内容

当然,以上内容仅仅是 POSIX 线程 API 的一点皮毛。有些用户可能发现需要使用 pthread_join() 调用,这个函数可以使一个线程处于等待状态,直到另一个线程执行完成。这里有些可以设置的属性,以便完成对调度的控制。在 Web 上有许多关于 POSIX 线程的参考资料。记得要阅读使用手册。您可以使用命令 apropos pthreadman -k pthread 得到与 pthread 相关的使用手册。


相关主题

  • 您可以参阅本文在 developerWorks 全球站点上的 英文原文.
  • 本文中用到的所有文件都可以作为一个 tar 文件 一次下载或者从以下链接单独下载:
  • Daniel Robbins 在 developerWorks上一个由三部分组成的系列文章“ POSIX 线程详解”中介绍了 POSIX 线程 API。
  • Open Directory 上有一个 POSIX 线程专栏,罗列了许多库、教程和 FAQ。
  • IBM AIX 资源 POSIX 线程 API—— 都摘自 IBM eServer Solutions,这些都是非常有用的资料。
  • 关于 AIX 的书籍 General programming concepts: Writing and debugging programs中的两个章节“ Understanding threads and processes”和“ Multi-threaded programming”中的内容适用于其他平台上的线程程序设计。
  • GNU Portable Threads,或者称为 GNU Pth,提供了一个与 POSIX 兼容的线程的免费软件实现,而 GNU 软件的 操作手册一向是非常好的。
  • 走向 Linux 2.6”讨论了内核抢占、futexes、新的调度程序以及更多的内容最新变化( developerWorks,2003 年 11 月)。
  • 在“ 管理进程和线程”( developerWorks,2002 年 2 月)一文中,Ed Bradford 集中讲述了 Linux 和 Windows 环境中的线程和进程。
  • 要了解更关于 POSIX 线程的内容,包括连接和调度线程,请阅读 Lawrence Livermore National Laboratories 的在线教程 POSIX threads programming和 Little Unix Programmers Group (LUPG) 的 Multi-threaded programming with POSIX threads
  • 这儿有一本 理解互斥量和锁的指南,它虽然不是基于 POSIX 线程进行讲解的,但是所涉及的问题具有通用性。
  • 关于 pthread_createpthread_joinpthread_mutex_initpthread_cond_init 的手册页是非常有用的。您可以在终端窗口中查看它们,或者从 Linux Man Pages Online找到它们。
  • Peter 在 SUSE LinuxNetBSD上测试了他编写的掷骰子程序。
  • 并不是所有人都热衷于线程。在 1996 年与 Usenix 的一次谈话中,Tcl 语言的发明者 John Ousterhout (现在供职于 Electric Cloud, Inc,那时在 Sun 公司工作)认为用事件比用线程更好,可以通过他在 贝尔实验室的个人页面,或者 他 1996 年的幻灯片(PDF 格式)来了解他的观点。
  • Anne McCaffrey 是一位作家,他喜欢写 Pern 星球上和龙相关的神话故事。在 Pern 星球上有一道可怕的“银线(silver threads)”忽然降落,让那些关心的人感到恐慌。要问 Anne 的一个问题是:这些从地球来的殖民者,他们一定要乘坐某种飞船飞越很长的一段距离到 Pern 星球去进行殖民统治 —— 到第二本书他们还没有发明 望远镜
  • 网络掷骰子程序在角色扮演类游戏(即 RPG)中非常有用,您从来没有玩过这类游戏吗?可以通过 Paul Elliot 的 什么是角色扮演?或者 Dave 的 角色扮演介绍了解更多的内容。如果您确信已经明白,可以到 Nethack得到 Amulet of Yendor。

评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=21696
ArticleTitle=pthreads 的基本用法
publish-date=03012004