Skip to content

Latest commit

 

History

History
788 lines (532 loc) · 36 KB

File metadata and controls

788 lines (532 loc) · 36 KB

三、C++ 多线程应用编程接口

虽然 C++ 在标准模板库 ( STL 中有本机多线程实现,但操作系统级和基于框架的多线程应用编程接口仍然非常常见。这些应用编程接口的例子包括窗口和 POSIX ( 可移植操作系统接口)线程,以及QtBoostPOCO库提供的线程。

本章详细介绍了每种 API 提供的特性,以及它们之间的异同。最后,我们将使用示例代码来看看常见的使用场景。

本章涵盖的主题包括:

  • 可用多线程应用编程接口的比较
  • 这些应用编程接口的使用示例

API 概述

C++ 2011 ( C++ 11 )标准之前,开发了许多不同的线程实现,其中许多仅限于特定的软件平台。其中一些在今天仍然适用,例如 Windows 线程。其他的已经被标准所取代,其中 POSIX 线程 ( Pthreads )已经成为了类 UNIX 操作系统上事实上的标准。这包括基于 Linux 和基于 BSD 的操作系统,以及 OS X (macOS)和 Solaris。

开发许多库是为了使跨平台开发更容易。虽然 Pthreads 有助于使类似 UNIX 的操作系统或多或少地兼容,这是使软件可移植到所有主要操作系统的先决条件之一,但需要一个通用的线程应用编程接口。这就是为什么创建了诸如 Boost、POCO 和 Qt 这样的库。应用可以使用这些,并依靠库来处理平台之间的任何差异。

POSIX 线程

从 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 和类似的软件广泛使用。

Windows 支持

也可以使用有限的方式使用 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 提供的特性。

PThreads 线程管理

这些都是以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函数,稍后我们将把它传递给新线程。出于演示和调试的目的,可以先添加一个简单的coutprintf为基础的业务逻辑位,打印出发送给新线程的值。

接下来,我们定义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 的方法相当复杂。

Windows 线程

相对于 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 的线程支持不能按原样添加到现有代码中,而是需要修改代码以适应框架。

QThread

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(); 
} 

构造函数可以扩展到包括参数。任何堆分配的变量(使用mallocnew)必须在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

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 返回时显式检查其条件是否满足,因此给开发人员带来了更少的负担。

C++ 线程

第 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 实现基本功能。

在下一章中,我们将详细介绍如何同步线程以及它们之间的通信。