虽然 C++ 在标准模板库 ( STL 中有本机多线程实现,但操作系统级和基于框架的多线程应用编程接口仍然非常常见。这些应用编程接口的例子包括窗口和 POSIX ( 可移植操作系统接口)线程,以及Qt
、Boost
和POCO
库提供的线程。
本章详细介绍了每种 API 提供的特性,以及它们之间的异同。最后,我们将使用示例代码来看看常见的使用场景。
本章涵盖的主题包括:
- 可用多线程应用编程接口的比较
- 这些应用编程接口的使用示例
在 C++ 2011 ( C++ 11 )标准之前,开发了许多不同的线程实现,其中许多仅限于特定的软件平台。其中一些在今天仍然适用,例如 Windows 线程。其他的已经被标准所取代,其中 POSIX 线程 ( Pthreads )已经成为了类 UNIX 操作系统上事实上的标准。这包括基于 Linux 和基于 BSD 的操作系统,以及 OS X (macOS)和 Solaris。
开发许多库是为了使跨平台开发更容易。虽然 Pthreads 有助于使类似 UNIX 的操作系统或多或少地兼容,这是使软件可移植到所有主要操作系统的先决条件之一,但需要一个通用的线程应用编程接口。这就是为什么创建了诸如 Boost、POCO 和 Qt 这样的库。应用可以使用这些,并依靠库来处理平台之间的任何差异。
从 1995 年开始,Pthreads 首先在POSIX.1c
标准(线程扩展,IEEE 标准 1003.1c-1995)中被定义为 POSIX 标准的扩展。当时,UNIX 被选为制造商中立的接口,POSIX 统一了其中的各种 API。
尽管做了这种标准化努力,但由于不可移植的扩展(在方法名中用_np
标记),实现它的操作系统之间(例如,Linux 和 OS X 之间)的 Pthread 实现仍然存在差异。
对于pthread_setname_np
方法,Linux 实现采用两个参数,允许一个参数设置当前线程以外的线程名称。在 OS X(从 10.6 开始),这个方法只接受一个参数,只允许设置当前线程的名称。如果可移植性是一个问题,人们必须注意这些差异。
1997 年后,POSIX 标准的修订由奥斯汀联合工作组管理。这些修订将线程扩展合并到主标准中。目前的修订版是 7 版,也称为 POSIX.1-2008 版和 IEEE Std 1003.1,2013 版-该标准的免费副本可在网上获得。
操作系统可以被认证为符合 POSIX 标准。目前,如下表所示:
| 名称 | 显影剂 | 自版本起 | 架构(当前) | 注释 | | [计]高级交互执行程序(Advanced Interactive Executive) | 国际商用机器公司 | 5L | 力量 | 操作系统服务器 | | 惠普-UX | 惠普公司 | 11i v3 | PA-RISC、IA-64 (Itanium) | 操作系统服务器 | | 伊里克斯 | 硅图形 | six | 每秒百万条指令 | 停止 | | K-UX 警探 | -探长 | Two | X86_64, | 基于 Linux 的 | | 完整 | 青山软件 | five | ARM、XScale、Blackfin、飞思卡尔酷火、MIPS、PowerPC、x86。 | 实时操作系统 | | X/MacOS | 苹果 | 10.5(豹式) | X86_64 | 操作系统桌面 | | QNX 中微子 | 黑莓 | one | 英特尔 8088,x86,MIPS,PowerPC,SH-4,ARM,StrongARM,XScale | 实时嵌入式操作系统 | | Solaris | Sun/Oracle | Two point five | SPARC、IA-32 (<11)、x86_64、PowerPC (2.5.1) | 操作系统服务器 | | Tru64 | DEC、惠普、IBM、康柏 | 5.1B-4 | 希腊字母的第一个字母 | 停止 | | UnixWare(终极格斗锦标赛) | Novell、SCO、Xinuos | 7.1.3 | x86 | 操作系统服务器 |
其他操作系统大多是兼容的。以下是同样的例子:
| 名称 | 平台 | 注释 | | 机器人 | ARM、x86、MIPS | 基于 Linux。仿生 C 库。 | | 贝奥(俳句) | IA-32 臂 x64 | 仅限用于 x86 的 GCC 2.x。 | | 达尔文 | PowerPC、x86、ARM | 使用 macOS 所基于的开源组件。 | | FreeBSD | IA-32、x86_64、sparc64、PowerPC、ARM、MIPS 等等 | 本质上符合 POSIX。人们可以依赖记录在案的 POSIX 行为。总的来说,在合规性上比 Linux 更严格。 | | Linux 操作系统 | Alpha、ARC、ARM、AVR32、Blackfin、H8/300、Itanium、m68k、Microblaze、MIPS、Nios II、OpenRISC、PA-RISC、PowerPC、s390、S+core、SuperH、SPARC、x86、Xtensa 等等 | 一些 Linux 发行版(见上表)被认证为符合 POSIX。这并不意味着每个 Linux 发行版都符合 POSIX。一些工具和库可能与标准不同。对于 Pthreads,这可能意味着 Linux 发行版之间的行为有时是不同的(不同的调度程序等等),与实现 Pthreads 的其他操作系统相比也是不同的。 | | MINIX 3 电脑 | IA-32,手臂 | 符合 POSIX 规范标准 3 (SUSv3,2004)。 | | NetBSD | Alpha、ARM、PA-RISC、68k、MIPS、PowerPC、SH3、SPARC、RISC-V、VAX、x86 等等 | 几乎完全兼容 POSX.1 (1990),并且大部分符合 POSIX.2 (1992)。 | | 核 RTOS | ARM、MIPS、PowerPC、Nios II、MicroBlaze、SuperH 等等 | 来自 Mentor Graphics 的专有 RTOS,面向嵌入式应用。 | | NuttX | ARM、AVR、AVR32、HCS12、SuperH、Z80 等等 | 重量轻的 RTOS,可从 8 位系统扩展到 32 位系统,重点关注 POSIX 合规性。 | | OpenBSD | Alpha、x86_64、ARM、PA-RISC、IA-32、MIPS、PowerPC、SPARC 等等 | 1995 年从 NetBSD 分叉。类似的 POSIX 支持。 | | opensolaris/illus | IA-32、x86_64、SPARC、ARM | 符合被认证为兼容的商业 Solaris 发行版。 | | VxWorks | ARM,SH-4,x86,x86_64,MIPS,PowerPC | POSIX 兼容,具有用户模式执行环境的认证。 |
从这一点来看,很明显,遵循 POSIX 规范并不是一件容易的事情,并且可以指望在这些平台上编译自己的代码。每个平台还将有自己的一套标准扩展,用于标准中省略但仍然需要的功能。然而,Pthreads 被 Linux、BSD 和类似的软件广泛使用。
也可以使用有限的方式使用 POSIX APIs,例如,使用以下内容:
| 名称 | 符合性 | | Cygwin | 大部分是完整的。为 POSIX 应用提供完整的运行时环境,它可以作为普通的 Windows 应用分发。 | | 明哥 | 有了 MinGW-w64(MinGW 的重新开发),Pthreads 的支持相当完整,尽管有些功能可能会缺失。 | | 面向 Linux 的视窗子系统 | WSL 是 Windows 10 的一个特性,它允许一个 Ubuntu Linux 14.04 (64 位)映像的工具和实用程序在它上面本地运行,尽管不是那些使用 GUI 特性或缺少内核特性的工具和实用程序。否则,它会提供与 Linux 相似的合规性。该功能目前要求运行 Windows 10 周年更新,并使用微软提供的说明手动安装 WSL。 |
一般不建议在 Windows 上使用 POSIX。除非有充分的理由使用 POSIX(例如,现有的大型代码库),否则使用跨平台 API 之一要容易得多(本章稍后将介绍),它可以消除任何平台问题。
在接下来的部分中,我们将看看 Pthreads API 提供的特性。
这些都是以pthread_
或pthread_attr_
开始的功能。这些函数都适用于线程本身及其属性对象。
Pthreads 线程的基本用法如下:
#include <pthread.h>
#include <stdlib.h>
#define NUM_THREADS 5
主 Pthreads 表头是pthread.h
。这使得除了信号量之外的所有东西都可以访问(这一部分稍后将介绍)。我们还为希望从这里开始的线程数定义了一个常数:
void* worker(void* arg) {
int value = *((int*) arg);
// More business logic.
return 0;
}
我们定义了一个简单的Worker
函数,稍后我们将把它传递给新线程。出于演示和调试的目的,可以先添加一个简单的cout
或printf
为基础的业务逻辑位,打印出发送给新线程的值。
接下来,我们定义main
函数如下:
int main(int argc, char** argv) {
pthread_t threads[NUM_THREADS];
int thread_args[NUM_THREADS];
int result_code;
for (unsigned int i = 0; i < NUM_THREADS; ++ i) {
thread_args[i] = i;
result_code = pthread_create(&threads[i], 0, worker, (void*) &thread_args[i]);
}
我们在前面的函数中创建了一个循环中的所有线程。除了pthread_create()
函数返回的结果代码(成功时为零)之外,每个线程实例在创建时都会获得一个分配的线程标识(第一个参数)。线程标识是在未来调用中引用线程的句柄。
函数的第二个参数是pthread_attr_t
结构实例,如果没有,则为 0。这允许新线程的配置特征,例如初始堆栈大小。传递零时,将使用默认参数,这些参数因平台和配置而异。
第三个参数是指向新线程将开始的函数的指针。该函数指针被定义为返回指向无效数据(即自定义数据)的指针,并接受指向无效数据的指针的函数。这里,作为参数传递给新线程的数据是线程标识:
for (int i = 0; i < NUM_THREADS; ++ i) {
result_code = pthread_join(threads[i], 0);
}
exit(0);
}
接下来,我们等待每个工作线程使用完pthread_join()
函数。这个函数有两个参数,等待线程的标识,以及Worker
函数返回值的缓冲区(或零)。
管理线程的其他功能如下:
-
void pthread_exit
(void *value_ptr
): 这个函数终止调用它的线程,使得提供的参数值对任何调用它的pthread_join()
的线程可用。 -
int pthread_cancel
(pthread_t
线程): 该函数请求取消指定的线程。根据目标线程的状态,这将调用其取消处理程序。
除此之外,还有pthread_attr_*
功能来操纵和获取关于pthread_attr_t
结构的信息。
这些是前缀为pthread_mutex_
或pthread_mutexattr_
的功能。它们适用于互斥体及其属性对象。
Pthreads 中的互斥体可以被初始化、销毁、锁定和解锁。他们还可以使用pthread_mutexattr_t
结构定制他们的行为,该结构具有相应的pthread_mutexattr_*
功能,用于初始化和销毁其上的属性。
使用静态初始化的 Pthread 互斥体的基本用法如下:
static pthread_mutex_t func_mutex = PTHREAD_MUTEX_INITIALIZER;
void func() {
pthread_mutex_lock(&func_mutex);
// Do something that's not thread-safe.
pthread_mutex_unlock(&func_mutex);
}
在这最后一段代码中,我们使用PTHREAD_MUTEX_INITIALIZER
宏,它为我们初始化互斥体,而不必每次都为它键入代码。与其他 API 相比,我们必须手动初始化和销毁互斥锁,尽管宏的使用有些帮助。
之后,我们锁定和解锁互斥体。还有pthread_mutex_trylock()
函数,类似于常规的锁版本,但是如果引用的互斥体已经被锁定,它将立即返回,而不是等待它被解锁。
在这个例子中,互斥体没有被显式销毁。然而,这是基于 Pthreads 的应用中正常内存管理的一部分。
这些是前缀为pthread_cond_
或pthread_condattr_
的功能。它们适用于条件变量及其属性对象。
Pthreads 中的条件变量遵循相同的模式,除了具有相同的用于管理pthread_condattr_t
属性结构的功能之外,还具有初始化和destroy
功能。
本示例介绍了 Pthreads 条件变量的基本用法:
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#define COUNT_TRIGGER 10
#define COUNT_LIMIT 12
int count = 0;
int thread_ids[3] = {0,1,2};
pthread_mutex_t count_mutex;
pthread_cond_t count_cv;
在前面的代码中,我们获得了标准的头,并定义了计数触发器和限制,其目的将在稍后变得清晰。我们还定义了几个全局变量:一个计数变量,我们希望创建的线程的标识,以及一个互斥和条件变量:
void* add_count(void* t) {
int tid = (long) t;
for (int i = 0; i < COUNT_TRIGGER; ++ i) {
pthread_mutex_lock(&count_mutex);
count++ ;
if (count == COUNT_LIMIT) {
pthread_cond_signal(&count_cv);
}
pthread_mutex_unlock(&count_mutex);
sleep(1);
}
pthread_exit(0);
}
这个前面的函数,本质上,只是在用count_mutex
获得对全局计数器变量的独占访问权后添加到该变量中。它还检查是否已经达到计数触发值。如果有,它将向条件变量发出信号。
为了给同样运行这个函数的第二个线程一个获得互斥锁的机会,我们在循环的每个周期中休眠 1 秒钟:
void* watch_count(void* t) {
int tid = (int) t;
pthread_mutex_lock(&count_mutex);
if (count < COUNT_LIMIT) {
pthread_cond_wait(&count_cv, &count_mutex);
}
pthread_mutex_unlock(&count_mutex);
pthread_exit(0);
}
在第二个函数中,我们在检查是否已经达到计数限制之前锁定全局互斥体。这是我们的保险,以防运行这个函数的线程在计数达到极限之前没有被调用。
否则,我们等待提供条件变量和锁定互斥的条件变量。一旦发出信号,我们就解锁全局互斥体,并退出线程。
这里需要注意的一点是,这个例子没有考虑虚假唤醒。Pthreads 条件变量易受这种唤醒的影响,这需要使用循环来检查是否满足某种条件:
int main (int argc, char* argv[]) {
int tid1 = 1, tid2 = 2, tid3 = 3;
pthread_t threads[3];
pthread_attr_t attr;
pthread_mutex_init(&count_mutex, 0);
pthread_cond_init (&count_cv, 0);
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
pthread_create(&threads[0], &attr, watch_count, (void *) tid1);
pthread_create(&threads[1], &attr, add_count, (void *) tid2);
pthread_create(&threads[2], &attr, add_count, (void *) tid3);
for (int i = 0; i < 3; ++ i) {
pthread_join(threads[i], 0);
}
pthread_attr_destroy(&attr);
pthread_mutex_destroy(&count_mutex);
pthread_cond_destroy(&count_cv);
return 0;
}
最后,在main
函数中,我们创建了三个线程,其中两个运行添加到计数器的函数,第三个运行等待其条件变量发出信号的函数。
在这个方法中,我们还初始化了全局互斥体和条件变量。我们创建的线程进一步明确设置了“可连接”属性。
最后,我们等待每个线程完成,之后我们进行清理,在退出之前销毁属性结构实例、互斥体和条件变量。
使用pthread_cond_broadcast()
函数,还可以向所有等待条件变量的线程发送信号,而不仅仅是队列中的第一个线程。这使我们能够在一些应用中更好地使用条件变量,比如有许多工作线程等待新数据集的到来,而不必单独通知每个线程。
实现同步的功能以pthread_rwlock_
或pthread_barrier_
为前缀。这些实现了读/写锁和同步障碍。
一个读/写锁 ( rwlock )非常类似于一个互斥体,只是它有一个额外的特性,允许无限线程同时读取,同时只限制对单个线程的写访问。
使用rwlock
与使用互斥体非常相似:
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t* rwlock, const pthread_rwlockattr_t* attr);
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
在最后一段代码中,我们包含相同的通用头,并使用初始化函数或通用宏。有趣的是当我们锁定rwlock
时,这可以只针对只读访问进行:
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock);
在这里,如果锁已经被锁定,那么第二个变化立即返回。还可以锁定它进行写访问,如下所示:
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t * rwlock);
这些函数的工作原理基本相同,只是在任何给定时间只允许一个编写器,而多个读取器可以获得一个只读锁。
障碍是 Pthreads 的另一个概念。这些同步对象对许多线程来说就像一个屏障。所有这些都必须先到达障碍,然后才能通过。在屏障初始化函数中,指定线程计数。只有当所有这些线程都使用pthread_barrier_wait()
函数调用了barrier
对象时,它们才会继续执行。
如前所述,信号量不是 POSIX 规范最初 Pthreads 扩展的一部分。为此,它们在semaphore.h
标题中声明。
本质上,信号量是简单的整数,通常用作资源计数。为了使它们线程安全,使用了原子操作(检查和锁定)。POSIX 信号量支持信号量的初始化、销毁、递增和递减,以及等待信号量达到非零值。
使用 Pthreads,TLS 是通过使用键和方法来设置线程特定的数据来实现的:
pthread_key_t global_var_key;
void* worker(void* arg) {
int *p = new int;
*p = 1;
pthread_setspecific(global_var_key, p);
int* global_spec_var = (int*) pthread_getspecific(global_var_key);
*global_spec_var += 1;
pthread_setspecific(global_var_key, 0);
delete p;
pthread_exit(0);
}
在工作线程中,我们在堆上分配一个新的整数,并将全局键设置为它自己的值。在将全局变量增加 1 之后,它的值将是 2,而不管其他线程做什么。一旦我们完成了这个线程,我们可以将全局变量设置为 0,并删除分配的值:
int main(void) {
pthread_t threads[5];
pthread_key_create(&global_var_key, 0);
for (int i = 0; i < 5; ++ i)
pthread_create(&threads[i],0,worker,0);
for (int i = 0; i < 5; ++ i) {
pthread_join(threads[i], 0);
}
return 0;
}
全局键被设置并用于引用 TLS 变量,但是我们创建的每个线程都可以为这个键设置自己的值。
虽然线程可以创建自己的密钥,但是与我们在本章中看到的其他 API 相比,这种处理 TLS 的方法相当复杂。
相对于 Pthreads,Windows 线程仅限于 Windows 操作系统及类似系统(例如 ReactOS 和其他使用 Wine 的 OS)。这提供了一个相当一致的实现,很容易由支持对应的 Windows 版本来定义。
在 Windows Vista 之前,线程支持错过的功能,如条件变量,而具有在 Pthreads 中找不到的功能。根据个人的观点,不得不使用由窗口标题定义的无数“类型定义”类型也是一件麻烦的事情。
使用 Windows 线程的一个基本示例,改编自 MSDN 官方文档示例代码,如下所示:
#include <windows.h>
#include <tchar.h>
#include <strsafe.h>
#define MAX_THREADS 3
#define BUF_SIZE 255
在为线程函数、字符串等包含一系列特定于窗口的头之后,我们定义了我们希望创建的线程数量以及Worker
函数中消息缓冲区的大小。
我们还定义了一个结构类型(由void pointer: LPVOID
传递)来包含我们传递给每个工作线程的样本数据:
typedef struct MyData {
int val1;
int val2;
} MYDATA, *PMYDATA;
DWORD WINAPI worker(LPVOID lpParam) {
HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE);
if (hStdout == INVALID_HANDLE_VALUE) {
return 1;
}
PMYDATA pDataArray = (PMYDATA) lpParam;
TCHAR msgBuf[BUF_SIZE];
size_t cchStringSize;
DWORD dwChars;
StringCchPrintf(msgBuf, BUF_SIZE, TEXT("Parameters = %d, %dn"),
pDataArray->val1, pDataArray->val2);
StringCchLength(msgBuf, BUF_SIZE, &cchStringSize);
WriteConsole(hStdout, msgBuf, (DWORD) cchStringSize, &dwChars, NULL);
return 0;
}
在Worker
函数中,我们将提供的参数转换为我们的自定义结构类型,然后使用它将其值打印为字符串,并在控制台上输出。
我们还验证有一个活动的标准输出(控制台或类似的)。用于打印字符串的函数都是线程安全的。
void errorHandler(LPTSTR lpszFunction) {
LPVOID lpMsgBuf;
LPVOID lpDisplayBuf;
DWORD dw = GetLastError();
FormatMessage(
FORMAT_MESSAGE_ALLOCATE_BUFFER |
FORMAT_MESSAGE_FROM_SYSTEM |
FORMAT_MESSAGE_IGNORE_INSERTS,
NULL,
dw,
MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
(LPTSTR) &lpMsgBuf,
0, NULL);
lpDisplayBuf = (LPVOID) LocalAlloc(LMEM_ZEROINIT,
(lstrlen((LPCTSTR) lpMsgBuf) + lstrlen((LPCTSTR) lpszFunction) + 40) * sizeof(TCHAR));
StringCchPrintf((LPTSTR)lpDisplayBuf,
LocalSize(lpDisplayBuf) / sizeof(TCHAR),
TEXT("%s failed with error %d: %s"),
lpszFunction, dw, lpMsgBuf);
MessageBox(NULL, (LPCTSTR) lpDisplayBuf, TEXT("Error"), MB_OK);
LocalFree(lpMsgBuf);
LocalFree(lpDisplayBuf);
}
这里定义了一个错误处理函数,它获取最后一个错误代码的系统错误消息。获取最后一个错误的代码后,要输出的错误消息被格式化,并显示在消息框中。最后,释放分配的内存缓冲区。
最后,main
功能如下:
int _tmain() {
PMYDATA pDataArray[MAX_THREADS];
DWORD dwThreadIdArray[MAX_THREADS];
HANDLE hThreadArray[MAX_THREADS];
for (int i = 0; i < MAX_THREADS; ++ i) {
pDataArray[i] = (PMYDATA) HeapAlloc(GetProcessHeap(),
HEAP_ZERO_MEMORY, sizeof(MYDATA)); if (pDataArray[i] == 0) {
ExitProcess(2);
}
pDataArray[i]->val1 = i;
pDataArray[i]->val2 = i+100;
hThreadArray[i] = CreateThread(
NULL, // default security attributes
0, // use default stack size
worker, // thread function name
pDataArray[i], // argument to thread function
0, // use default creation flags
&dwThreadIdArray[i]);// returns the thread identifier
if (hThreadArray[i] == 0) {
errorHandler(TEXT("CreateThread"));
ExitProcess(3);
}
}
WaitForMultipleObjects(MAX_THREADS, hThreadArray, TRUE, INFINITE);
for (int i = 0; i < MAX_THREADS; ++ i) {
CloseHandle(hThreadArray[i]);
if (pDataArray[i] != 0) {
HeapFree(GetProcessHeap(), 0, pDataArray[i]);
}
}
return 0;
}
在main
函数中,我们在一个循环中创建我们的线程,为线程数据分配内存,并在启动线程之前为每个线程生成唯一的数据。每个线程实例都被传递了自己唯一的参数。
之后,我们等待线程完成并重新加入。这本质上与用 Pthreads 在单个线程上调用join
函数相同——仅在这里,一次函数调用就足够了。
最后,关闭每个线程句柄,我们清理之前分配的内存。
Windows 线程的高级线程管理包括作业、纤维和线程池。作业本质上允许一个人将多个线程链接到一个单一的单元中,使一个人能够一次改变所有这些线程的属性和状态。
纤维是轻量级的线,在制造它们的线的上下文中运行。创建线程需要自己调度这些光纤。纤维也有类似于 TLS 的纤维本地存储 ( FLS )。
最后,视窗线程应用编程接口提供了一个线程池应用编程接口,允许人们在自己的应用中轻松使用这样的线程池。每个进程还提供了一个默认线程池。
使用 Windows 线程,互斥和同步可以使用关键部分、互斥体、信号量、纤细的读/写器 ( SRW )锁、屏障和变体来完成。
同步对象包括以下内容:
| 名称 | 描述 | | 事件 | 允许使用命名对象在线程和进程之间发送事件信号。 | | 互斥(体)… | 用于线程间和进程同步,以协调对共享资源的访问。 | | 旗语 | 标准信号量计数器对象,用于线程间和进程同步。 | | 可等待计时器 | 计时器对象可由多个进程使用,具有多种使用模式。 | | 临界截面 | 关键部分本质上是互斥体,仅限于单个进程,由于缺少内核空间调用,这使得它们比使用互斥体更快。 | | 超薄读取器/写入器锁 | SRWs 类似于 Pthreads 中的读/写锁,允许多个读取器或单个写入器线程访问共享资源。 | | 互锁变量访问 | 允许对一系列变量进行原子访问,否则这些变量不能保证是原子的。这使得线程能够共享一个变量,而不必使用互斥体。 |
用 Windows 线程实现条件变量相当简单。它使用临界区(CRITICAL_SECTION
)和条件变量(CONDITION_VARIABLE
)以及条件变量函数来等待特定的条件变量,或者发出信号。
【Windows 线程的线程本地存储 ( TLS )与 Pthreads 类似,首先必须创建一个中心键(TLS 索引),之后各个线程可以使用该全局索引来存储和检索本地值。
与 Pthreads 一样,这也涉及类似数量的手动内存管理,因为 TLS 值必须手动分配和删除。
Boost 线程是 Boost 库集合中相对较小的一部分。然而,它被用作 C++ 11 中多线程实现的基础,类似于其他 Boost 库最终如何将其全部或部分纳入新的 C++ 标准。有关多线程应用编程接口的详细信息,请参考本章中的 C++ 线程部分。
在 Boost 线程中可用的 C++ 11 标准中缺少的特性包括:
- 线程组(如 Windows 作业)
- 线程中断(取消)
- 超时线程连接
- 其他互斥锁类型(用 C++ 14 改进)
除非绝对需要这样的特性,或者如果不能使用支持 C++ 11 标准的编译器(包括 STL 线程),那么在 C++ 11 实现上使用 Boost 线程是没有什么理由的。
由于 Boost 提供了本机操作系统特性的包装器,因此根据 STL 实现的质量,使用本机 C++ 线程可能会减少开销。
Qt 是一个比较高级的框架,这也体现在它的多线程 API 上。Qt 的另一个定义特性是它包装了自己的代码(QApplication 和 QMainWindow),并使用元编译器(qmake
)来实现它的信号槽架构和框架的其他定义特性。
因此,Qt 的线程支持不能按原样添加到现有代码中,而是需要修改代码以适应框架。
Qt 中的QThread
类不是一个线程,而是一个围绕线程实例的广泛包装器,它增加了信号槽通信、运行时支持和其他特性。这反映在 QThread 的基本用法中,如以下代码所示:
class Worker : public QObject {
Q_OBJECT
public:
Worker();
~Worker();
public slots:
void process();
signals:
void finished();
void error(QString err);
private:
};
前面这段代码是一个基本的Worker
类,它将包含我们的业务逻辑。它源自QObject
类,也允许我们使用信号槽和其他固有的QObject
特性。信号槽体系结构的核心只是侦听器注册(连接)由 QObject 派生类声明的信号的一种方式,允许跨模块、跨线程和异步通信。
它有一个单一的,可以被调用来开始处理,和两个信号-一个信号表示完成,一个信号表示错误。
实现如下所示:
Worker::Worker() { }
Worker::~Worker() { }
void Worker::process() {
qDebug("Hello World!");
emit finished();
}
构造函数可以扩展到包括参数。任何堆分配的变量(使用malloc
或new
)必须在process()
方法中分配,而不是在构造函数中分配,因为Worker
实例将在线程上下文中操作,我们稍后会看到。
要创建新的 QThread,我们将使用以下设置:
QThread* thread = new QThread;
Worker* worker = new Worker();
worker->moveToThread(thread);
connect(worker, SIGNAL(error(QString)), this, SLOT(errorString(QString)));
connect(thread, SIGNAL(started()), worker, SLOT(process()));
connect(worker, SIGNAL(finished()), thread, SLOT(quit()));
connect(worker, SIGNAL(finished()), worker, SLOT(deleteLater()));
connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater()));
thread->start();
基本过程是在堆上创建一个新的 QThread 实例(这样它就不会超出范围)以及我们的Worker
类的一个堆分配实例。然后,这个新的工作者将使用其moveToThread()
方法被移动到新的线程实例。
接下来,我们将把各种信号连接到相关的插槽,包括我们自己的finished()
和error()
信号。来自线程实例的started()
信号将被连接到我们的工人上的槽,该槽将启动它。
最重要的是,必须将来自工人的某种完成信号连接到螺纹上的quit()
和deleteLater()
槽。然后,来自螺纹的finished()
信号将连接到工人身上的deleteLater()
槽。这将确保工作线程和工作线程实例在工作线程完成后被清理。
Qt 提供线程池。这些需要从QRunnable
类继承一个,并实现run()
函数。这个定制类的一个实例然后被传递给线程池的start
方法(全局默认池,或者一个新的)。然后,这个工作线程的生命周期由线程池处理。
Qt 提供以下同步对象:
QMutex
QReadWriteLock
QSemaphore
QWaitCondition
(条件变量)
这些应该是不言自明的。Qt 信号槽体系结构的另一个很好的特性是,这些也允许一个人在线程之间异步通信,而不必关心底层的实现细节。
QtConcurrent 命名空间包含高级应用编程接口,旨在使编写多线程应用成为可能,而不必关心低级细节。
函数包括并发过滤和映射算法,以及允许在单独线程中运行函数的方法。所有这些都返回一个QFuture
实例,其中包含异步操作的结果。
Qt 通过其QThreadStorage
类提供 TLS。指针类型值的内存管理由它处理。通常,可以将某种数据结构设置为 TLS 值,以便每个线程存储多个值,例如在QThreadStorage
类文档中描述的:
QThreadStorage<QCache<QString, SomeClass> > caches;
void cacheObject(const QString &key, SomeClass* object) {
caches.localData().insert(key, object);
}
void removeFromCache(const QString &key) {
if (!caches.hasLocalData()) { return; }
caches.localData().remove(key);
}
概念验证库是操作系统功能的轻量级包装。它不需要 C++ 11 兼容的编译器或任何类型的预编译或元编译。
Thread
类是操作系统级线程的简单包装器。它采用从Runnable
类继承的Worker
类实例。官方文档对此提供了如下基本示例:
#include "Poco/Thread.h"
#include "Poco/Runnable.h"
#include <iostream>
class HelloRunnable: public Poco::Runnable {
virtual void run() {
std::cout << "Hello, world!" << std::endl;
}
};
int main(int argc, char** argv) {
HelloRunnable runnable;
Poco::Thread thread;
thread.start(runnable);
thread.join();
return 0;
}
前面的代码是一个非常简单的“Hello world”示例,工作人员只通过标准输出输出一个字符串。线程实例被分配在堆栈上,并保持在入口函数的范围内,等待工作者完成使用join()
函数。
由于它的许多线程功能,POCO 很像 Pthreads,尽管它在配置线程和其他对象等方面确实有很大的不同。作为一个 C++ 库,它使用类方法设置属性,而不是填充结构并将其作为参数传递。
POCO 提供了 16 个线程的默认线程池。这个数字可以动态改变。与普通线程一样,线程池需要传递一个从Runnable
类继承而来的Worker
类实例:
#include "Poco/ThreadPool.h"
#include "Poco/Runnable.h"
#include <iostream>
class HelloRunnable: public Poco::Runnable {
virtual void run() {
std::cout << "Hello, world!" << std::endl;
}
};
int main(int argc, char** argv) {
HelloRunnable runnable;
Poco::ThreadPool::defaultPool().start(runnable);
Poco::ThreadPool::defaultPool().joinAll();
return 0;
}
工作实例被添加到运行它的线程池中。当我们添加另一个工作实例、改变容量或调用joinAll()
时,线程池会清理已经空闲了一定时间的线程。结果,单个工作线程将加入,并且没有活动线程,应用退出。
使用 POCO,TLS 被实现为一个类模板,允许人们在几乎任何类型中使用它。
如官方文件所详述:
#include "Poco/Thread.h"
#include "Poco/Runnable.h"
#include "Poco/ThreadLocal.h"
#include <iostream>
class Counter: public Poco::Runnable {
void run() {
static Poco::ThreadLocal<int> tls;
for (*tls = 0; *tls < 10; ++(*tls)) {
std::cout << *tls << std::endl;
}
}
};
int main(int argc, char** argv) {
Counter counter1;
Counter counter2;
Poco::Thread t1;
Poco::Thread t2;
t1.start(counter1);
t2.start(counter2);
t1.join();
t2.join();
return 0;
}
在前面的工作示例中,我们使用ThreadLocal
类模板创建了一个静态 TLS 变量,并将其定义为包含一个整数。
因为我们将其定义为静态的,所以每个线程只会创建一次。为了使用我们的 TLS 变量,我们可以使用箭头(->
)或星号(*
)运算符来访问它的值。在本例中,我们在for
循环的每个周期增加一次 TLS 值,直到达到极限。
这个例子演示了两个线程将生成它们自己的 10 个整数的序列,通过相同的数字计数而不会相互影响。
POCO 提供的同步原语如下所示:
- 互斥(体)…
- 快速互斥体
- 事件
- 情况
- 旗语
- 鲁洛克
这里值得注意的是FastMutex
班。这通常是非递归互斥类型,除了在 Windows 上,它是递归的。这意味着人们通常应该假设这两种类型都是递归的,因为同一线程可以多次锁定同一个互斥体。
也可以在ScopedLock
类中使用互斥体,这确保了它封装的互斥体在当前范围的末尾被释放。
事件类似于窗口事件,只是它们仅限于单个进程。它们构成了概念验证中条件变量的基础。
POCO 条件变量的功能与 Pthreads 和其他变量非常相似,只是它们不会受到虚假唤醒的影响。通常情况下,由于优化原因,条件变量会受到这些随机唤醒的影响。由于不必在条件变量 wait 返回时显式检查其条件是否满足,因此给开发人员带来了更少的负担。
在第 5 章、原生 C++ 线程和原语中广泛介绍了 C++ 中的原生多线程支持。
正如本章前面的 Boost 部分提到的,C++ 多线程支持在很大程度上基于 Boost 线程 API,使用几乎相同的头和名称。该应用编程接口本身再次让人想起 Pthreads,尽管在条件变量等方面有很大的不同。
接下来的章节将专门使用 C++ 线程支持作为示例。
在本章介绍的 API 中,只有 Qt 多线程 API 可以被认为是真正的高级。虽然其他 API(包括 C++ 11)有一些更高级的概念,包括线程池和异步运行器,它们不需要一个直接使用线程,但是 Qt 提供了一个成熟的信号槽架构,这使得线程间的通信异常容易。
正如本章所述,这种便利也是有代价的,即必须开发自己的应用来适应 Qt 框架。根据项目的不同,这可能不可接受。
这些 API 中哪一个是正确的取决于一个人的需求。然而,相对公平地说,当一个人可以使用诸如 C++ 11 线程、POCO 等 API 时,使用直接的 Pthreads、Windows 线程和 kin 没有太大意义,这些 API 在不显著降低性能的情况下简化了开发过程,同时还获得了跨平台的广泛可移植性。
所有的应用编程接口至少在它们的核心特性上有一定的可比性。
在本章中,我们详细介绍了一些比较流行的多线程 API 和框架,将它们放在一起,以便了解它们的优缺点。我们通过一些例子展示了如何使用这些 API 实现基本功能。
在下一章中,我们将详细介绍如何同步线程以及它们之间的通信。