本章简要介绍了线程API的主要部分。随着我们展示如何使用API,每个部分将在后续章节中进一步解释。更多细节可以在各种书籍和网络资源中找到[B89, B97, B+96, K+96]。我们应该注意到,后面的章节介绍锁和条件变量的概念会比较慢,有很多例子;因此,本章用作参考比较好。
关键的问题:如何创建和控制线程 操作系统应该提供哪些接口来创建和控制线程?如何设计这些接口,使其既易于使用又实用?
27.1 线程创建 Thread Creation
要编写一个多线程程序,首先必须能够创建新的线程,因此必须存在某种类型的线程创建接口。在POSIX中,很简单:
这个声明可能看起来有点复杂(特别是如果您没有在C中使用函数指针),但实际上它并不是太糟糕。有四个参数:thread、attr、start_routine和arg。第一个thread是指向pthread_t类型结构体的指针;我们将使用这个结构与这个线程进行交互,因此需要将它传递给pthread_create()来初始化它。
第二个参数attr用于指定该线程可能具有的任何属性。一些示例包括设置栈大小或有关线程调度优先级的信息。通过单独调用pthread_attr_init()初始化属性;详细操作请参见手册页面。然而,在大多数情况下,默认设置就可以了;在本例中,我们将简单地传入NULL值。
第三个参数是最复杂的,但实际上只是在问:这个线程应该在哪个函数中开始运行?在C语言中,我们称之为一个函数指针(function pointer),这个告诉我们以下期望: 一个函数名(start_routine),这是通过一个参数类型为void (start_routine后的括号表示),并返回一个void 类型的值(例如,一个空指针(void pointer))。
如果这个例程(routine)要求的是整型参数,而不是void指针,则声明如下:
相反,如果例程接受一个void指针作为参数,但返回一个整数,它将是这样的:
最后,第四个参数arg,就是要传递给线程开始执行的函数的参数。你可能会问:为什么我们需要这些void指针?答案很简单:用一个void指针作为函数start_routine的参数允许我们传入任何类型的参数;将它作为返回值允许线程返回任何类型的结果。
让我们看看图27.1中的一个例子。在这里,我们只是创建了一个线程,它传递了两个参数,打包成我们自己定义的单个类型(myarg_t)。线程一旦创建,就可以简单地将其参数转换为它期望的类型,从而按需要解包(unpack)参数。
就是这样!一旦你创建了一个线程,你就真的有另一个实时执行实体(live executing entity),它有自己的调用栈(call stack),在与程序中所有当前存在的线程相同的地址空间中运行。乐趣就这样开始了!
27.2 线程完成 Thread Completion
上面的示例展示了如何创建线程。但是,如果您想等待一个线程完成,会发生什么呢?为了等待完成,你需要做一些特别的事情;特别是,必须调用例程pthread_join()。
这个例程有两个参数。第一个类型是pthread_t,用于指定等待哪个线程。这个变量由线程创建例程(thread creation routine)初始化(当你将一个指向它的指针作为参数传递给pthread_create()时);如果您保留它,您可以使用它来等待线程终止。
第二个参数是一个指针,它指向您希望返回的返回值。因为例程可以返回任何东西,所以它被定义为返回一个指向void的指针;因为pthread_join()例程会改变传入参数的值,所以需要传入指向该值的指针,而不仅仅是值本身。
让我们看另一个例子(图27.2)。在代码中,再次创建了一个线程,并通过myarg_t结构传递了两个参数。要返回值,需要使用myret_t类型。一旦线程完成运行,一直在pthread_join()例程中等待的主线程就会返回,我们可以访问线程返回的值,即myret_t中的值。
注意,这里我们使用了封装函数;具体来说,我们调用Malloc()、Pthread_join()和Pthread_create(),它们只是调用它们命名相似的小写版本,并确保例程不会返回任何意外的结果。
关于这个示例,有几点需要注意。首先,通常情况下,我们不需要做所有这些痛苦的参数的打包和拆包。例如,如果我们只是创建一个没有参数的线程,我们可以在线程创建时将NULL作为参数传入。类似地,如果不关心返回值,也可以将NULL传递给pthread_join()。
第二,如果我们只是传递一个值(例如,一个long long int),我们不需要把它打包成一个参数。图27.3显示了一个示例。在这种情况下,事情会简单一些,因为我们不必在结构内部打包参数和返回值。
第三,我们应该注意,必须非常小心如何从线程返回值。具体来说,永远不要返回指向线程调用栈上分配的对象的指针。如果你这样做,你认为会发生什么?(想想!)下面是一个危险代码片段的示例,它是从图27.2中的示例中修改的。
在这种情况下,变量oops是在mythread的栈上分配的。然而,当它返回时,该值会自动被释放(这就是为什么栈如此容易使用的原因!),因此,将指针传递回现在已释放的变量将导致各种糟糕的结果。当然,当您打印出您认为已返回的值时,您可能(但不一定!)会感到惊讶。试试吧,你自己看看!
幸运的是,当您编写这样的代码时,编译器gcc很可能会抱怨,这是注意编译器警告的另一个原因。
最后,您可能会注意到,使用pthread_create()创建线程,然后立即调用pthread_join(),这是一种非常奇怪的创建线程的方法。事实上,有一种更简单的方法来完成这个任务;这叫做过程调用(procedure call)。显然,我们通常会创建不止一个线程,然后等待它完成,否则使用线程根本就没有多大用处。
我们应该注意,并不是所有的多线程代码都使用join例程。例如,一个多线程web服务器可能会创建许多工作线程,然后使用主线程接受请求并将请求无限期地传递给工作线程。因此,这些长期存在的项目可能不需要join。然而,创建线程来执行特定任务的并行程序(并行地)很可能使用join来确保在退出或进入下一个计算阶段之前完成所有这些工作。
27.3 锁 Locks
除了线程创建和join之外,POSIX线程库提供的下一个最有用的函数集可能是那些通过锁(locks)向临界区(critical section)提供互斥(mutual exclusion)的函数。下面提供了用于此目的的最基本的一对例程:
例程(routines)应该易于理解和使用。当代码区域是临界区域,因此需要保护以确保正确操作时,锁非常有用。你可以想象代码是什么样的:
代码的目的如下:如果调用pthread_mutex_lock()时没有其他线程持有锁,线程将获得锁并进入临界区。如果另一个线程确实持有锁,那么试图获取锁的线程将不会从调用中返回,直到它获得了锁(意味着持有锁的线程已经通过unlock调用释放了锁)。当然,在给定的时间内,许多线程可能被困在锁获取函数中等待;但是,只有获得锁的线程才应该调用unlock。
不幸的是,这段代码在两个重要方面被破坏了。第一个问题是缺乏适当的初始化(lack of proper initialization)。所有锁都必须正确初始化,以确保它们具有正确的初始值,从而在调用lock和unlock时能够按预期工作。
对于POSIX线程,有两种方法来初始化锁。一种方法是使用PTHREAD_MUTEX_INITIALIZER,如下所示:
这样做会将锁设置为默认值,从而使锁可用。动态的方法(即在运行时)是调用pthread_mutex_init(),如下所示:
这个例程的第一个参数是锁本身的地址,而第二个参数是一组可选属性。自己去了解更多的属性;传入NULL只会使用默认值。两种方法都可以,但我们通常使用动态(后一种)方法。注意,当你用完锁之后,也应该调用pthread_mutex_destroy();详情请参阅手册页。
上述代码的第二个问题是,当调用lock和unlock时,它无法检查错误代码。就像在UNIX系统中调用的任何库例程一样,这些例程也可能失败!如果您的代码没有正确地检查错误代码,则失败将无声地发生,在这种情况下,可能会允许多个线程进入临界区。至少,使用包装器,它断言例程成功,如图 27.4 所示;更复杂的(非玩具)程序在出现问题时不能简单地退出,应该检查失败并在调用不成功时做一些适当的事情。
锁和解锁例程并不是pthreads库中唯一与锁交互的例程。另外两个有趣的例行程序:
这两个调用用于获取锁。如果锁已经被持有,trylock版本将返回失败;获取锁的timedlock版本在超时或获取锁之后返回,以最先发生的为准。因此,超时为0的timedlock退化为trylock情况。这两种版本通常都应该避免;然而,在一些情况下,避免在获取锁例程中卡住(可能是无限期的)是有用的,我们将在以后的章节中看到(例如,在我们研究死锁时)。
27.4 条件变量 Condition Variables
任何线程库的另一个主要组件(POSIX线程当然也是如此)是条件变量(condition variable)的存在。当线程之间必须发生某种信号时,条件变量很有用,如果一个线程在继续之前等待另一个线程执行某些操作。希望以这种方式交互的程序使用两个主要的例程:
要使用条件变量,还必须拥有与该条件相关联的锁。当调用上述任何一个例程时,这个锁都应该被持有。
第一个例程是pthread_cond_wait(),它将调用线程置于睡眠状态,并因此等待其他线程发出信号,通常是在程序中发生了一些变化,而现在处于睡眠状态的线程可能关心这些变化的时候。一个典型的用法如下:
在这段代码中,在初始化相关锁和条件之后,线程检查ready变量是否已经被设置为非零的值。如果不是,线程就直接调用wait例程以进入睡眠状态,直到其他线程唤醒它。
可以使用pthread_cond_init()(和pthread_cond_destroy())来代替静态初始化器PTHREAD_COND_INITIALIZER。听起来有更多工作要做?是的。
唤醒一个线程的代码,它将在其他线程中运行,看起来像这样:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/12377925/1631965018234-ddee3063-84ff-4f7d-a673-eda9c3f4e2d6.png#clientId=ub2e704e2-cf60-4&from=paste&height=120&id=u8653c2d6&margin=%5Bobject%20Object%5D&name=image.png&originHeight=120&originWidth=474&originalType=binary&ratio=1&size=14472&status=done&style=none&taskId=u5e3106fa-a3a1-40db-866f-9635ddeee6b&width=474)<br />关于这个代码序列,有几点需要注意。首先,**当发出信号(以及修改全局变量ready时)时,我们总是确保持有锁**。这确保了我们不会意外地在代码中引入竞争条件。<br />其次,您可能会注意到,wait调用接受锁作为它的第二个参数,而signal调用只接受一个条件。产生这种差异的原因是,wait调用除了让调用线程进入睡眠状态外,还会在让调用者进入睡眠状态时释放锁。想象一下,如果它没有:其他线程如何获得锁并发出信号唤醒它?但是,在被唤醒后,返回之前,pthread_cond_wait()重新获取锁,从而确保在等待序列开始的锁获取和最后的锁释放之间运行等待线程的任何时间(也就是被唤醒后直到下一次睡眠之前),等待线程都持有锁。<br />最后一个奇怪的地方是:**等待的线程在while循环中重新检查条件,而不是简单的if语句**。我们将在以后的章节中学习条件变量时详细讨论这个问题,但一般来说,使用while循环是一种简单而安全的做法。尽管它会重新检查条件(可能会增加一点开销),**但有些pthread实现可能会虚假地唤醒正在等待的线程**;在这种情况下,在不重新检查的情况下,等待的线程将继续认为条件已经改变,即使它没有。**因此,更安全的做法是将醒来视为某种事情可能发生变化的暗示,而不是一个绝对的事实**。<br />请注意,有时很容易使用一个简单的标志来在两个线程之间发出信号,而不是使用条件变量和关联的锁。例如,我们可以重写上面的wait代码,使其看起来更像下面的代码:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/12377925/1631967725450-2047e10e-8eee-4299-b28a-9871899d032a.png#clientId=ub2e704e2-cf60-4&from=paste&height=76&id=u461365b4&margin=%5Bobject%20Object%5D&name=image.png&originHeight=76&originWidth=321&originalType=binary&ratio=1&size=4555&status=done&style=none&taskId=ua1203857-8067-44b3-a4ce-15f98942154&width=321)<br />相关的信号代码看起来像这样:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/12377925/1631967746665-c587d711-2859-492d-94db-4ee895a5cb80.png#clientId=ub2e704e2-cf60-4&from=paste&height=32&id=ud6774012&margin=%5Bobject%20Object%5D&name=image.png&originHeight=32&originWidth=183&originalType=binary&ratio=1&size=1784&status=done&style=none&taskId=u3e8f9c72-5171-4061-a67e-c20cc0fdad1&width=183)<br />千万不要这样做,原因如下。首先,在许多情况下,它的性能很差(长时间在while中徘徊只会浪费CPU周期)。其次,它容易出错。正如最近的研究显示[X+10],当使用标志(如上所示)在线程之间同步时,很容易犯错误;在那项研究中,大约一半的这些特别的同步的使用是有缺陷的!**不要懒惰;使用条件变量,即使你认为你可以不这样做**。<br />如果条件变量听起来令人困惑,不要太担心(目前),我们将在后续章节中详细介绍它们。在此之前,只要知道它们的存在,并对它们的使用方式和原因有所了解,就足够了。
27.5 编译和运行 Compiling and Running
本章中的所有代码示例都相对容易启动和运行。要编译它们,必须在代码中包含头文件pthread.h。在链接行上,还必须通过添加-pthread标志来显式链接pthreads库。
例如,要编译一个简单的多线程程序,您所要做的就是如下所示:
只要main.c包含pthreads头文件,就成功编译了一个并发程序。它是否像往常一样有效,则完全是另一回事。
27.6 总结 Summary
我们介绍了pthread库的基础知识,包括线程创建、通过锁构建互斥以及通过条件变量发出信号和等待。你不需要太多的其他东西来编写健壮和高效的多线程代码,除了耐心和大量的细心!
我们现在以一组Tips结束本章,这些Tips在您编写多线程代码时可能对您有用(有关详细信息,请参阅接下来的Aside)。API 的其他方面也很有趣;如果您想了解更多信息,请在 Linux 系统上键入 man -k pthread 以查看构成整个接口的一百多个 API。但是,这里讨论的基础知识应该使您能够构建复杂的(并且希望是正确和高性能的)多线程程序。线程的难点不是 API,而是如何构建并发程序的棘手逻辑。请继续阅读以了解更多信息。
Aside:线程API引导 当您使用POSIX线程库(或者实际上任何线程库)来构建多线程程序时,有许多小但重要的事情需要记住。它们是:
- 保持简单(Keep it simple)。最重要的是,线程之间的任何锁或者信号代码都应该尽可能简单。棘手的线程交互会导致错误。
- 减少线程交互(Minimize thread interactions)。尽量减少线程交互的方式。每一次互动都应该经过仔细的思考,并使用经过验证的真实方法(我们将在接下来的章节中学习其中的许多方法)来构建。
- 初始化锁和条件变量(Initialize locks and condition variables)。如果不这样做,代码就会时而有效,时而以非常奇怪的方式失败。
- 检查你的返回码(Check your return codes)。当然,在任何C和UNIX编程中,都应该检查每个返回代码,这里也是如此。如果不这样做,你的行为就会很奇怪,很难理解,你可能会(a)尖叫,(b)扯掉一些头发,或者(c)两者兼有。
- 要小心向线程传递参数和从线程返回值的方式(Be careful with how you pass arguments to, and return values from, threads)。特别是,任何时候,当你传递一个引用给一个在栈上分配的变量时,你可能做了一些错误的事情。
- 每个线程都有自己的栈(Each thread has its own stack)。与上面的观点相关,请记住每个线程都有自己的堆栈。因此,如果你在某个线程正在执行的函数中有一个局部分配的变量,它本质上是该线程的私有变量;没有其他线程可以(容易地)访问它。要在线程之间共享数据,这些值必须位于堆(heap)中或全局可访问的某个区域中。
- 总是使用条件变量在线程之间发出信号(Always use condition variables to signal between threads)。虽然使用简单的标志通常很诱人,但不要这样做。
- 请使用手册页面(Use the manual pages)。特别是在Linux上,pthread手册页提供了大量信息,并讨论了这里给出的许多细微差别,通常会更加详细。仔细阅读!
References
[B89] “An Introduction to Programming with Threads” by Andrew D. Birrell. DEC Technical Report, January, 1989. Available: https://birrell.org/andrew/papers/035-Threads.pdf
A classic but older introduction to threaded programming. Still a worthwhile read, and freely available.
[B97] “Programming with POSIX Threads” by David R. Butenhof. Addison-Wesley, May 1997.
Another one of these books on threads.
[B+96] “PThreads Programming: by A POSIX Standard for Better Multiprocessing. ” Dick Buttlar, Jacqueline Farrell, Bradford Nichols.
O’Reilly, September 1996 A reasonable book from the excellent, practical publishing house O’Reilly. Our bookshelves certainly contain a great deal of books from this company, including some excellent offerings on Perl, Python, and Javascript (particularly Crockford’s “Javascript: The Good Parts”.)
[K+96] “Programming With Threads” by Steve Kleiman, Devang Shah, Bart Smaalders. Pren- tice Hall, January 1996.
Probably one of the better books in this space. Get it at your local library. Or steal it from your mother. More seriously, just ask your mother for it – she’ll let you borrow it, don’t worry.
[X+10] “Ad Hoc Synchronization Considered Harmful” by Weiwei Xiong, Soyeon Park, Jiaqi Zhang, Yuanyuan Zhou, Zhiqiang Ma. OSDI 2010, Vancouver, Canada.
This paper shows how seemingly simple synchronization code can lead to a surprising number of bugs. Use condition variables and do the signaling correctly!
Homework (Code)
在本节中,我们将编写一些简单的多线程程序,并使用一个名为helgrind的特定工具来查找这些程序中的问题。有关如何构建程序和运行helgrind的详细信息,请阅读作业下载中的README。
Questions
- 首先建立main-race.c。检查代码,以便您可以看到代码中的数据竞争(希望是显而易见的)。现在运行helgrind(输入valgrind —tool=helgrind main-race),看看它是如何报告竞争的。它是否指向正确的代码行?它还能给你什么信息?
- 当您删除其中一行违规代码时会发生什么?现在在共享变量的一个更新周围添加一个锁,然后在两个更新周围添加锁。在这些例子中,helgrind报告了什么?
- 现在让我们看看main-deadlock.c。检查代码。这段代码有一个被称为死锁的问题(我们将在下一章中更深入地讨论这个问题)。你能看出它可能有什么问题吗?
- 现在在这段代码上运行helgrind。helgrind的报告是什么?
- 现在在main-deadlock-global.c上运行helgrind。检查代码;它是否有与main-deadlock.c相同的问题?helgrind应该报告相同的错误吗?关于helgrind工具,这说明了什么?
- 让我们看看main-signal.c。这段代码使用一个变量(done)来表示子程序已经完成,父线程现在可以继续了。为什么这个代码是低效的?(当子线程需要很长时间才能完成时,父线程最终会花时间做什么?)
- 现在在这个程序上运行helgrind。它报告了什么?代码正确吗?
- 现在看看代码的一个稍微修改过的版本,在main-signal-cv.c中可以找到。这个版本使用一个条件变量来发送信号(和相关的锁)。为什么这个代码比以前的版本更受欢迎?是正确,还是表现,还是两者兼而有之?
- 再一次在main-signal-cv上运行helgrind。它报告错误吗?