1 多线程编程基础

线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,每个进程至少有一个线程,即主线程。线程依赖于进程,不能单独存在。线程的主要作用是实现并发执行,即多个线程可以同时执行不同的任务。
多线程编程能够充分利用多核处理器或多处理器系统的优势,通过同时执行多个线程来提高程序的执行性能。在具有多个核心的现代计算机系统中,多线程编程可以确保每个核心都能得到充分利用,从而实现并行处理,显著提高程序的执行速度。另外,对于交互式应用程序,如用户界面或网络服务,多线程编程可以显著提高系统的响应性。通过将耗时的操作(如文件读写、网络请求等)放在单独的线程中执行,可以避免阻塞主线程,保持用户界面的流畅和响应。这对于提供良好的用户体验至关重要。

1.1 C++ 中的多线程编程

在 C++ 中,多线程编程指的是编写能够在同一时间或同一时间段内执行多个执行线程的程序。每个线程代表一个独立的执行路径,它们共享程序的地址空间,但拥有自己独立的栈空间。这意味着多个线程可以并发地执行不同的任务,从而提高程序的执行效率。
C++ 中的多线程编程涉及以下几个关键概念:
线程( Thread ):线程是程序执行的最小单元。每个线程都有自己的指令指针、栈和局部变量。在多线程环境中,多个线程可以并发执行,共享程序的内存空间(堆和静态存储区),但每个线程有自己的栈空间。
进程( Process ):进程是操作系统分配资源的基本单位,它包含了一个程序的执行实例。一个进程可以包含一个或多个线程,至少包含一个主线程。
线程生命周期:线程的生命周期包括创建、就绪、运行、阻塞和终止等状态。线程可以通过系统调用、用户请求或程序控制等方式进入不同的状态。
线程同步:由于多个线程可能同时访问共享资源,因此需要机制来同步线程的执行,避免数据冲突和不一致。线程同步包括互斥锁( Mutexes )、条件变量( Condition Variables )、信号量( Semaphores)等。
线程通信:线程间可能需要交换数据或信息以协调它们的工作。线程通信可以通过共享内存、消息队列、管道、套接字等方式实现。
线程安全性:线程安全性指的是在多线程环境中,代码能够正确地处理多个线程同时访问共享数据的情况,而不会导致数据不一致或其他错误。
在 C++11 之前,跨平台的多线程编程通常需要使用第三方库或平台特定的 API 。由于 C++ 标准库在 C++11 之前没有提供对多线程的原生支持,因此开发者需要依赖特定平台的线程库或者跨平台的线程库。
以下是一些在 C++11 之前常用的跨平台多线程编程方法:
(1)使用POSIX线程( Pthreads ):
Pthreads 是一个在类 Unix 系统(如 Linux 和 macOS )上广泛使用的多线程编程接口。 Pthreads 提供了一套丰富的 API 来创建和管理线程,以及进行线程同步。然而, Pthreads 是类 Unix 系统特有的,因此在 Windows 平台上无法使用。
(2)使用 Windows 线程( Win32 threads ):
Windows 平台提供了自己的线程 API ,即 Win32 线程。 Win32 线程 API 允许开发者在 Windows 操作系统上创建和管理线程。然而,与 Pthreads 一样, Win32 线程也是平台特有的,无法在 Unix 或类 Unix 系统上使用。
C++11 标准引入了对多线程的原生支持,包括 <thread> 、<mutex> 、 <condition_variable> 等头文件,这些头文件提供了创建和管理线程、同步线程以及进行线程间通信的工具。此外,C++标准库还提供了线程安全的容器(如 std::vector 、 std::list 等),这些容器可以在多线程环境中安全地使用。

1.2 线程与进程的区别

线程与进程在操作系统中各自扮演不同的角色,并具有显著的区别。以下是线程与进程的主要区别:
(1)资源分配与调度
进程:进程是资源分配的基本单位,它拥有独立的地址空间、数据栈和其他系统资源。当创建一个新进程时,操作系统会为其分配必要的资源,并确保它与其他进程隔离。进程间的切换涉及较多资源的管理,因此效率相对较低。
线程:线程是CPU调度的基本单位,它共享进程的资源(如内存空间、打开的文件等),但拥有独立的执行栈和程序计数器。线程切换时只需保存和恢复少量寄存器内容,因此切换开销小,效率高。
(2)执行方式
进程:进程是独立的执行实体,拥有自己的地址空间和系统资源。一个进程崩溃不会影响其他进程的执行。
线程:线程是进程内的一条执行路径,多个线程共享进程的资源。同一个进程内的线程间通信较为容易,因为它们可以直接访问共享内存空间。然而,一个线程的错误可能导致整个进程的崩溃。
(3)并发性
进程:由于进程拥有独立的地址空间,多个进程可以同时执行,实现真正的并发。
线程:线程之间共享进程的资源,因此多个线程可以同时执行,但它们实际上是在同一个地址空间内并发执行。
(4)独立性
进程:进程之间相互独立,一个进程的状态不会影响其他进程。
线程:线程是进程的一部分,它们共享进程的资源,因此线程之间的独立性相对较低。
(5)系统开销
进程:由于进程拥有独立的资源,创建和销毁进程涉及较多资源的管理,因此开销较大。
线程:线程创建和销毁的开销相对较小,因为它们共享进程的资源。
总的来说,进程和线程在资源分配、调度、执行方式、并发性、独立性和系统开销等方面存在显著的区别。在选择使用进程还是线程时,需要根据具体的应用场景和需求进行权衡。

1.3 多线程编程的优势与挑战

多线程编程的优势主要体现在以下几个方面:
(1)提高性能
多线程编程能够充分利用多核处理器或多处理器系统的优势,实现并行处理,从而提高程序的执行性能。通过将任务分解为多个线程并同时执行,可以显著提高程序的运行速度。
(2)增强响应性
对于交互式应用程序,如用户界面或网络服务,多线程编程可以显著提高系统的响应性。通过将耗时的操作放在单独的线程中执行,可以避免阻塞主线程,保持用户界面的流畅和响应。这对于提供良好的用户体验至关重要。
(3)简化设计
多线程编程可以简化某些复杂问题的设计。通过将大问题分解为多个小问题,并使用多个线程分别处理这些小问题,可以使程序结构更加清晰,便于理解和维护。
(4)资源利用率
多线程编程可以提高系统的资源利用率。多个线程可以共享计算机的资源,如CPU、内存、硬盘等,从而更有效地利用系统资源。
然而,多线程编程也面临一些挑战:
(1)线程同步与数据竞争
多个线程同时访问共享资源时,需要采取适当的同步措施来避免数据竞争和不一致性问题。线程同步可能涉及互斥锁、条件变量等机制,这些机制的使用需要谨慎,否则可能导致死锁或性能下降。
(2)复杂性增加
多线程编程增加了程序的复杂性。线程之间的交互和同步需要仔细设计,以避免出现竞态条件、死锁等问题。此外,线程的管理和调试也比单线程程序更加复杂。
(3)性能开销
虽然多线程编程可以提高性能,但线程的创建、销毁和上下文切换都需要一定的开销。如果线程数量过多或频繁切换,可能会导致性能下降。
(4)可靠性问题
多线程编程可能导致一些可靠性问题。例如,一个线程的异常或错误可能导致整个进程的崩溃,影响其他线程的执行。
因此,在编写多线程程序时,需要权衡其优势与挑战,并采取适当的措施来确保程序的正确性和性能。

1.4 C++11 中的多线程支持

C++11 标准引入了对多线程的原生支持,为开发者提供了更加便捷和高效的方式来编写多线程程序。在 C++11 中,引入了以下几个关键组件来支持多线程编程:
(1)<thread> 头文件
这个头文件包含了std::thread类,用于创建和管理线程。开发者可以使用 std::thread 对象来表示一个线程,并通过调用其成员函数来执行线程任务。
(2)<atomic> 头文件
这个头文件提供了原子操作的支持,包括 std::atomic 类和一套 C 风格的原子类型与原子操作函数。原子操作是一种在多线程环境中安全执行的操作,即在执行过程中不会被其他线程打断,从而保证了数据的一致性和正确性。
(3)<mutex> 头文件
这个头文件提供了互斥量(mutex)的支持,用于同步线程间的访问共享资源。互斥量是一种常用的同步机制,可以确保同一时间只有一个线程可以访问共享资源,从而避免数据竞争和不一致性问题。
(4)<condition_variable> 头文件
这个头文件提供了条件变量的支持,用于线程间的条件同步。条件变量允许一个或多个线程等待某个条件成立,当条件满足时,等待的线程可以被唤醒并继续执行。
(5)<future> 头文件
这个头文件提供了异步任务的支持,包括 std::future 和 std::promise 等类。这些类允许开发者启动一个异步任务,并在需要时获取其结果。这对于实现异步编程和并发计算非常有用。
通过使用这些头文件和类, C++11 使得多线程编程更加简单和直观。开发者可以更加容易地创建和管理线程,实现线程间的同步和通信,从而编写出高效且可靠的多线程程序。需要注意的是,虽然 C++11 提供了多线程支持,但编写多线程程序仍然需要谨慎处理线程同步和数据竞争等问题,以确保程序的正确性和性能。

2 线程的创建与管理

在 C++11 中,线程的创建与管理主要通过 std::thread 类来实现。使用 C++11 创建与管理线程的主要流程如下:
(1)创建线程
可以使用 std::thread 的构造函数创建一个新线程,并传递给它一个可调用对象(例如函数、函数指针、成员函数指针、 Lambda 表达式等)。这个可调用对象将在新线程中执行。
(2)管理线程
线程被创建后,可以使用std::thread类的成员函数来管理:
join() : 阻塞当前线程,直到被调用的线程完成执行。
detach() : 将线程标记为分离状态,允许它在后台运行,并且不需要显式调用 join() 。分离状态的线程在结束时会自动释放其资源。
get_id() : 获取线程的唯一标识符。
hardware_concurrency() : 返回可用于执行线程的硬件并发性级别,通常对应于CPU的核数。
(3)线程状态
线程可以有以下几种状态:
joinable : 线程可以被 join 。这是线程刚被创建时的默认状态。
detached : 线程是分离的,即它将在后台运行,并且在结束时自动释放资源。
joined : 线程已经被 join ,并且不再处于活动状态。

2.1 使用 std::thread 创建线程

在 C++11 中,使用 std::thread 创建线程主要有两种方式:
(1)通过全局可调用对象创建线程
可以将任何全局可调用对象(如函数、 Lambda 表达式、 bind 表达式、函数对象等)传递给 std::thread 的构造函数来创建线程。这是 C++11 引入的更现代和灵活的方式。如下为样例代码:

#include <iostream>
#include <thread>
#include <string>
#include <functional>

void threadFunc(std::string str) 
{
	// 线程执行的代码  
	printf("%s\n", str.c_str());
}

class FuncClass
{
public:
	void operator()(std::string str)
	{
		// 线程执行的代码  
		printf("%s\n", str.c_str());
	}
};

int main() 
{
	// 通过函数指针创建线程
	std::thread t1(threadFunc,"function pointer");
	t1.join();

	// 通过 Lambda 表达式创建线程
	std::thread t2([]{
		// 线程执行的代码  
		printf("Lambda expression\n");
	});
	t2.join();

	// 通过 bind 表达式创建线程
	std::function<void(std::string)> func = std::bind(threadFunc, std::placeholders::_1);
	std::thread t3(func, "bind expression");
	t3.join();

	// 通过函数对象创建线程
	FuncClass funcObj;
	std::thread t4(funcObj, "function object");
	t4.join();

	return 0;
}

上面代码的输出为:

function pointer
Lambda expression
bind expression
function object

在上面代码中,分别使用函数指针、 Lambda 表达式、 bind 表达式、函数对象这四种方式通过 std::thread 创建了线程,随后调用 t.join() 阻塞 main 线程,直到新线程 t 执行完毕。这是一种同步机制,确保主线程等待新线程完成后再继续执行。如果不希望主线程等待,可以使用 t.detach() 来将新线程设置为分离状态,这样新线程将在后台运行,并且当它的任务完成后会自动释放资源。
(2)通过成员函数指针和对象实例创建线程
如果想要在新线程上调用类的成员函数,则需要传递一个成员函数指针和一个类的实例给 std::thread 。在这个过程中,可以使用 std::bind 或者 Lambda 表达式来绑定成员函数和对象实例。如下为样例代码:

#include <iostream>
#include <thread>
#include <string>

class MyClass
{
public:
	void threadFunc(std::string str)
	{
		// 线程执行的代码  
		printf("%s\n", str.c_str());
	}
};

int main() 
{
	MyClass obj;

	// 使用成员函数指针和对象实例创建线程  
	std::thread t1(&MyClass::threadFunc, &obj, "member function pointers and object instances");
	t1.join();

	// 使用lambda表达式和this指针创建线程  
	std::thread t2([&obj]() {
		obj.threadFunc("Lambda expressions and this pointers");
	});
	t2.join();

	return 0;
}

上面代码的输出为:

member function pointers and object instances
Lambda expressions and this pointers

2.2 std::thread 线程的启动与终止

std::thread 对象的启动和终止是通过其构造函数和成员函数来管理的。
启动线程
要启动一个新线程,需要创建一个 std::thread 对象,并在其构造函数中提供要在新线程上执行的函数或可调用对象。然后新线程会立即启动并开始执行提供的函数。如下为样例代码:

#include <iostream>
#include <thread>
#include <string>

void threadFunc()
{
	// 线程执行的代码  
	printf("hello thread\n");
}

int main() 
{
	std::thread t(threadFunc);
	t.join();

	return 0;
}

上面代码的输出为:

hello thread

在上面代码中,std::thread t(threadFunc); 语句创建并启动了一个新线程,该线程执行 threadFunc 函数。
终止线程
在 C++11 中,线程可以通过两种方式终止:
(1)隐式终止:当线程函数执行完成后,线程会自动终止。在上面的例子中, threadFunc 函数执行完毕后,对应的线程就会自然终止。
(2)显式终止:通过调用线程对象的detach或join成员函数来显式地管理线程的终止:
join :调用线程对象的 join 成员函数会阻塞当前线程(通常是主线程),直到被调用的线程执行完毕。这是一种同步机制,确保主线程等待新线程完成后再继续执行。
detach :调用线程对象的 detach 成员函数会将线程设置为分离状态。这意味着一旦线程函数执行完成,线程对象会自动释放其资源,而无需显式调用 join 。设置为分离状态的线程在其完成时会自动终止。如下为样例代码:

#include <iostream>
#include <thread>
#include <string>

void threadFunc()
{
	// 线程执行的代码  
	std::this_thread::sleep_for(std::chrono::milliseconds(10));
	printf("hello thread\n");
}

int main() 
{
	std::thread t(threadFunc);

	// 分离线程(线程完成时自动释放资源)  
	t.detach();

	// 主线程继续执行,不再等待myThread线程  
	printf("continue main thread\n");

	// 避免子线程没有结束,整个程序即退出
	std::this_thread::sleep_for(std::chrono::milliseconds(100));
	return 0;
}

上面代码的输出为:

continue main thread
hello thread

在上面代码中,由于调用了detach,主线程不会等待 t 线程完成,而是继续执行。当 threadFunc 函数执行完毕后, t 线程会自动终止,并且其资源会被自动释放。
注意事项
(1)一个线程对象只能被 join() 或 detach() 一次。尝试对一个已经 join() 或 detach() 过的线程对象再次调用这些函数会导致未定义行为。
(2)如果线程对象在其生命周期结束前既没有被 join() 也没有被 detach() ,程序会在终止时抛出 std::terminate() 异常。为了避免这种情况,通常建议在线程对象销毁前确保它被 join() 或 detach() 。
(3)在调用 join() 或 detach() 后,线程对象就不再代表一个可执行的线程,它变成了一个空线程对象,可以再次被赋值新的线程。
(4)在分离状态下运行的线程不应该访问任何需要在线程结束时保持有效的资源,因为线程对象可能会在线程函数返回后立即被销毁。

2.3 std::thread 线程的 ID

注意:std::thread 不提供获取当前线程的系统 ID (例如,在 POSIX 系统上的线程 ID 或 Windows 上的线程句柄)的方法。如果需要获取系统级别的线程 ID ,则要使用平台特定的 API。
在 C++ 中,std::thread 类型的对象代表一个线程,并且每个线程都有一个与之关联的线程 ID ,该 ID 的类型是 std::thread::id 。线程 ID 是线程的唯一标识符,可以在程序内部用于区分不同的线程。
std::thread::id 类型提供了两个主要的成员函数来获取线程 ID :
get_id() :这是 std::thread 类的成员函数,用于获取与线程对象关联的线程ID。如果线程对象没有与任何线程关联(例如,如果它是默认构造的或已经通过调用 join() 或 detach() 终止了),则 get_id() 返回一个默认构造的 std::thread::id 对象(值为 0 ),通常表示没有线程。
std::this_thread::get_id() :这是一个自由函数,用于获取当前执行线程的线程 ID 。这个函数在任何线程中都可以调用,包括主线程和由 std::thread 对象表示的任何线程。
如下是使用 get_id() 和 std::this_thread::get_id() 的样例代码:

#include <iostream>  
#include <thread>  

void threadFunc()
{
	// 获取当前线程的ID  
	std::thread::id threadId = std::this_thread::get_id();

	std::cout << "current thread id : " << threadId << std::endl;
}

int main() 
{
	// 创建并启动一个新线程  
	std::thread t(threadFunc);

	// 获取主线程的 ID  
	std::thread::id mainThreadId = std::this_thread::get_id();

	// 等待子线程完成  
	t.join();

	std::cout << "main thread id : " << mainThreadId << std::endl;


	return 0;
}

上面代码的输出为:

current thread id : 7572
main thread id : 12492

注意,虽然使用 std::cout 将 std::thread::id 输出的结果是一个整数(使用 std::thread::id 定义的流插入运算符来输出线程 ID ,输出的格式可能会因平台而异),但实际上将 std::thread::id 直接转换为 int 是不可行的,因为 std::thread::id 是一个平台相关的类型,它可能不是基于整数的,也没有提供直接的转换机制到整数。此外,std::thread::id 的内部表示和大小在不同平台和编译器实现之间可能会有所不同。std::thread::id 的设计目的是提供一个唯一的标识符,而不是一个可以安全转换为字符串的类型,因此最安全的做法是直接输出或使用 std::thread::id 对象本身。
在开发过程中,可以使用 std::thread::id 的比较操作符( == 、 != )来检查两个线程 ID 是否相同或不同。此外,可以通过比较 std::thread::id 对象与默认构造的 std::thread::id 对象来检查一个线程是否有效。

3 线程同步与互斥

线程同步与互斥是处理并发编程中线程之间交互和共享资源时的两个重要概念。它们有助于确保数据的一致性和防止竞态条件。
线程同步
线程同步是指协调多个线程的执行顺序,以确保它们之间的交互按照预期的方式进行。线程同步通常使用同步原语来实现,如互斥锁( mutexes )、条件变量( condition variables )、信号量( semaphores )等。
线程同步的目的是:
(1)保护共享资源:确保同时只有一个线程可以访问或修改共享资源,以防止数据不一致或损坏。
(2)控制执行顺序:确保线程按照预定的顺序执行,例如先执行某个线程,然后再执行另一个线程。
(3)避免死锁:通过合理的同步机制,确保线程在等待资源时不会陷入死锁状态。
线程互斥
互斥是指确保同一时刻只有一个线程可以访问某个共享资源或执行某段代码。这通常通过互斥锁( mutex )来实现,互斥锁是一种同步原语,用于保护共享资源不被多个线程同时访问。
互斥的的目的是:
(1)保护数据一致性:确保共享资源在多个线程之间的访问不会导致数据不一致或损坏。
(2)防止竞态条件:竞态条件是指多个线程在没有同步的情况下访问共享资源,导致结果取决于线程的执行顺序。互斥可以确保只有一个线程在任何时候访问共享资源,从而消除竞态条件。

3.1 std::mutex (互斥锁)

在C++中, std::mutex 是一个类,它定义在 头文件中,用于实现互斥锁( mutual exclusion )。互斥锁是一种同步机制,用于保护共享资源,防止多个线程同时访问和修改这些资源,从而避免数据竞争和不一致。

3.1.1 std::mutex 的定义与初始化

要定义和初始化一个 std::mutex 对象,只需要声明一个该类型的变量即可。如下为样例代码:

#include <mutex> // 包含互斥锁的头文件  

// 定义全局的互斥锁对象  
std::mutex g_mutex;

int main() {
	// 定义并初始化一个局部的互斥锁对象  
	std::mutex localMutex;

	// 使用互斥锁  
	g_mutex.lock();  // 锁定全局互斥锁  
	// 执行需要互斥访问的代码  
	g_mutex.unlock(); // 解锁全局互斥锁  

	localMutex.lock();  // 锁定局部互斥锁  
	// 执行需要互斥访问的代码 
	localMutex.unlock(); // 解锁局部互斥锁  

	return 0;
}

在上面带啊吗中, g_mutex 是一个全局的 std::mutex 对象,它在程序开始执行时就被创建并初始化。 localMutex 是一个在 main 函数内部定义的局部 std::mutex 对象,它在声明时就被创建并初始化。
一般是不需要显式调用 std::mutex 的构造函数来初始化互斥锁,因为编译器会自动调用默认构造函数来初始化对象。如果需要更复杂的初始化,可以使用构造函数参数来提供。但通常直接使用 std::mutex mutexName ;这种简单的声明和初始化方式就足够了。
注意:使用互斥锁时,应当谨慎地锁定和解锁,以避免死锁或资源争用。此外,尽可能的使用 std::lock_guard 或 std::unique_lock 等 RAII 风格的锁包装器来自动管理锁的生命周期,这样可以减少错误并提高代码的安全性。

3.1.2 std::mutex 的锁定和解锁

std::mutex 类提供了 lock() , unlock() 和 try_lock() 这 3 个成员函数来管理互斥锁的锁定和解锁。
lock() 方法
lock() 方法用于锁定互斥锁。如果互斥锁已经被另一个线程锁定,那么调用 lock() 的线程将会被阻塞,直到互斥锁变得可用为止。这是一种阻塞操作,意味着线程会等待直到能够获取锁。如下为样例代码:

std::mutex mtx;  
  
// 锁定互斥锁  
mtx.lock();  

unlock() 方法
unlock()函数用于解锁互斥锁,使得其他线程可以锁定它。在调用 unlock() 之前,必须先成功调用 lock() 来锁定互斥锁(否则会抛出异常,在没有捕获的情况下会导致程序崩溃)。如下为样例代码:

std::mutex mtx;  
  
// 锁定互斥锁  
mtx.lock();  
  
// 执行需要互斥访问的代码  
  
// 解锁互斥锁  
mtx.unlock();

try_lock() 方法
try_lock() 函数用于尝试锁定互斥锁,如果锁已经被其他线程持有,那么它不会阻塞当前线程,而是立即返回一个表示是否成功获取锁的值。通常,这个函数返回一个布尔值, true 表示成功获取锁, false 表示未能获取锁。如下为样例代码:

#include <iostream>  
#include <thread>  
#include <mutex>  
#include <chrono>  

std::mutex g_mutex; // 全局互斥锁  

void printBlockStr(int num, std::string str) 
{
	// 尝试锁定互斥锁  
	if (g_mutex.try_lock())
	{
		try {
			for (int i = 0; i < num; ++i)
			{
				printf("%s",str.c_str());
			}
		}
		catch (...) {
			g_mutex.unlock(); // 如果在打印过程中发生异常,确保解锁互斥锁  
			throw;
		}
		g_mutex.unlock(); // 解锁互斥锁  
	}
	else
	{
		printf("g_mutex is locked, cannot print now.");
	}
}

int main() {
	std::thread t1(printBlockStr, 10, "*");
	std::thread t2(printBlockStr, 10, "~");

	t1.join();
	t2.join();

	return 0;
}

上面代码的输出为:

****g_mutex is locked, cannot print now.******
// 上面输出中的字符串 "g_mutex is locked, cannot print now." 在星号中间是由于 t2 线程在 t1 打印过程中执行了语句输出。

在上面代码中,有两个线程 t1 和 t2 ,它们都尝试使用 try_lock() 来锁定同一个互斥锁 g_mutex 。如果 g_mutex 是可用的,线程将打印出相应数量的字符,并在完成后解锁互斥锁。如果 g_mutex 已经被另一个线程锁定,线程将输出一条消息,表明它无法打印。
需要注意的是, try_lock() 不提供任何超时机制。如果互斥锁被锁定,它会立即返回 false 。如果需要一个带超时的尝试锁定功能,则要使用 std::condition_variable 或者 std::future 与 std::async 来实现。
此外,在 printBlockStr 函数中,我们使用了try块来确保在打印过程中如果发生任何异常,互斥锁仍然能够被正确解锁。这是一种良好的编程实践,可以避免因异常导致的互斥锁死锁。

3.1.3 std::lock_guard 的使用

std::lock_guard 是 C++11 标准库中引入的一个类模板,用于管理互斥锁( std::mutex )的生命周期。它遵循 RAII(Resource Acquisition Is Initialization) 原则,即在构造时获取资源(在这种情况下是互斥锁),并在析构时释放资源。通过使用 std::lock_guard ,开发者可以确保互斥锁在适当的时候被锁定和解锁,从而避免手动管理锁时可能出现的错误和复杂性。
std::lock_guard 的优势包括:
(1)自动管理锁的生命周期: std::lock_guard 在构造时自动锁定互斥锁,并在析构时自动解锁。这意味着即使在异常或提前返回的情况下,锁也能被正确地释放,从而避免死锁或资源泄漏。
(2)简化锁的管理:使用 std::lock_guard ,不需要显式调用 lock() 和 unlock() 方法。这减少了出错的机会,并使代码更加简洁。
(3)局部作用域控制: std::lock_guard 的作用域通常限制在其声明的代码块内。这意味着锁的保护范围清晰可见,易于理解和维护。
(4)防止重复锁定:由于 std::lock_guard 在构造时锁定互斥锁,并且在析构时解锁,因此它不允许同一互斥锁被多次锁定。这有助于避免潜在的竞态条件。
std::lock_guard 的用法非常简单,如下为样例代码:

#include <iostream>  
#include <thread>  
#include <mutex>  
#include <chrono>  

std::mutex g_mutex; // 全局互斥锁  

void printBlockStr(int num, std::string str) 
{
	// 构造 std::lock_guard 对象,自动锁定 g_mutex  
	std::lock_guard<std::mutex> lock(g_mutex);

	for (int i = 0; i < num; ++i)
	{
		printf("%s", str.c_str());
	}

	printf("\n");
}

int main() {
	std::thread t1(printBlockStr, 10, "*");
	std::thread t2(printBlockStr, 10, "~");

	t1.join();
	t2.join();

	return 0;
}

上面代码的输出为:

**********
~~~~~~~~~~

在上面代码中,std::lock_guardstd::mutex lock(g_mutex); 创建了一个 std::lock_guard 对象,并在构造时自动锁定了 g_mutex 互斥锁。 std::lock_guard 对象的生命周期与它的作用域绑定,因此当 lock 对象离开其作用域(在这个例子中是 printBlockStr 函数的末尾)时,它的析构函数会被调用,从而自动解锁 g_mutex 。
std::lock_guard的使用确保了即使在 printBlockStr 函数中出现异常或提前返回的情况下, g_mutex 也会被正确地解锁,避免了死锁和资源泄漏的问题。
注意:std::lock_guard 是不可复制的,这意味着不能复制一个已经锁定了互斥锁的 std::lock_guard 对象到另一个对象,这有助于防止因复制而导致的潜在问题。

3.1.4 std::unique_lock 的使用

std::unique_lock 是一个互斥锁包装器,它允许开发者以独占所有权的方式( unique ownership )管理互斥锁。这意味着在 std::unique_lock 对象的生命周期内,没有其他 std::unique_lock 对象可以同时拥有同一个互斥锁的所有权。 std::unique_lock 也支持延迟锁定、手动解锁以及与其他同步原语(如条件变量)一起使用。 std::unique_lock 提供了一种更灵活的方式来管理互斥锁( std::mutex )的锁定和解锁。与上面的 std::lock_guard 相比, std::unique_lock 提供了更多的控制选项和功能。
std::unique_lock 的优势包括:
(1)灵活性: std::unique_lock 比 std::lock_guard 更加灵活。它允许延迟锁定(即在构造时不立即锁定互斥锁),手动解锁(通过调用 unlock() 方法),以及重新锁定(通过调用 lock() 方法)。此外, std::unique_lock 还可以与条件变量一起使用,以实现更复杂的同步模式。
(2)可移动性: std::unique_lock 对象可以被移动( move ),这意味着它可以被用作函数的返回值,也可以被存储在 STL 容器中。这使得 std::unique_lock 在编写更复杂的线程代码时更加有用。
(3)更好的错误处理: std::unique_lock 提供了更好的错误处理能力。如果尝试锁定一个已经被其他线程锁定的互斥锁, std::unique_lock 的构造函数将返回一个错误(如果使用了 std::try_to_lock 策略)。这使得开发者可以在代码中处理这种错误情况。
(4)支持多种锁定策略:std::unique_lock 支持多种锁定策略,包括独占锁和共享锁。默认情况下,它使用独占锁,但也可以通过指定 std::defer_lock 或 std::adopt_lock 来改变锁定行为。
总的来说, std::unique_lock 提供了比 std::lock_guard 更多的控制和灵活性,适用于需要更复杂同步操作的场景。然而,对于简单的锁定需求, std::lock_guard 通常更加简洁和易于使用。
创建 std::unique_lock 对象
std::unique_lock 的构造函数接受一个 std::mutex 对象作为参数,并在构造时锁定该互斥锁。

std::unique_lock<std::mutex> lock(g_mutex);

锁定和解锁互斥锁
类似于前面介绍的 std::lock_guard ,当 std::unique_lock 对象被创建时,它会锁定互斥锁。在 std::unique_lock 对象的生命周期结束时(例如离开作用域时),它会自动解锁互斥锁。这种自动管理锁的机制有助于避免忘记解锁而导致的问题。

{  
    std::unique_lock<std::mutex> lock(g_mutex);  
    // 在这里执行需要互斥访问的代码  
    // ...  
} // lock 对象离开作用域,自动解锁 g_mutex

延迟锁定和手动解锁
std::unique_lock 支持延迟锁定和手动解锁。可以使用 std::defer_lock 作为 std::unique_lock 的第二个参数来延迟锁定互斥锁,并在需要时调用 lock() 方法来手动锁定它。同样,可以使用 unlock() 方法来手动解锁互斥锁。

std::unique_lock<std::mutex> lock(g_mutex, std::defer_lock);  

// 在此处执行一些不需要互斥锁的操作   
  
// 手动锁定互斥锁 
lock.lock();  
	
// 在此处执行需要互斥锁的操作  

// 当 unique_lock 对象离开作用域时,它会自动解锁互斥锁  

尝试锁定
std::unique_lock 还提供了尝试锁定的功能。可以使用 try_lock() 方法来尝试锁定互斥锁,如果互斥锁已经被其他线程锁定,则 try_lock() 方法将返回 false ,否则返回 true 。这允许在尝试锁定失败时执行备选代码路径。

std::unique_lock<std::mutex> lock(g_mutex, std::try_to_lock);  
if (lock.owns_lock()) 
{  
    // 成功锁定互斥锁,执行需要互斥访问的代码  
    // ...  
} else {  
    // 锁定失败,执行备选代码路径  
    // ...  
}

配合条件变量使用
std::unique_lock 还经常与 std::condition_variable 一起使用,以实现线程之间的同步。可以使用 std::unique_lock 来锁定互斥锁,并在等待条件变量时释放锁,以便其他线程可以修改共享资源。当条件满足时,可以再次锁定互斥锁并继续执行。

#include <iostream>  
#include <thread>  
#include <mutex>  

std::mutex g_mutex; // 全局互斥锁  
std::condition_variable g_cv;
bool g_ready = false;

void threadFunc() 
{
	std::unique_lock<std::mutex> lock(g_mutex);
	g_cv.wait(lock, [] { return g_ready; }); // 等待条件变量,释放锁  
	// 条件满足,继续执行  
	// ...  
}

int main()
{
	std::thread t(threadFunc);
	{
		std::unique_lock<std::mutex> lock(g_mutex);
		// 修改共享资源  
		g_ready = true;
	} // lock对象离开作用域,自动解锁 g_mutex  
	g_cv.notify_one(); // 通知等待的线程条件已满足  
	t.join();
	return 0;
}

上面代码展示了如何使用 std::unique_lock 和 std::condition_variable 来实现线程之间的同步。工作线程在等待条件变量时释放了互斥锁,以便主线程可以修改共享资源。当主线程修改完共享资源并通知工作线程时,工作线程再次锁定互斥锁并继续执行。

3.2 std::condition_variable (条件变量)

std::condition_variable 是 C++11 引入的一个类,用于在并发编程中同步线程。它通常与互斥量( std::mutex )一起使用,允许一个或多个线程等待某个条件成立,而其他线程可以在该条件成立时通知等待的线程。

3.2.1 std::condition_variable 的基本使用

如下是一个简单的 std::condition_variable 使用示例:

#include <iostream>  
#include <thread>  
#include <mutex>  
#include <condition_variable>  

std::mutex g_mutex; // 全局互斥锁  
std::condition_variable g_cv;
bool g_ready = false;

void printID(int id)
{
	std::unique_lock<std::mutex> lock(g_mutex);
	while (!g_ready)  // 如果条件不满足,则等待  
	{
		g_cv.wait(lock); // 当前线程被阻塞,直到被通知  
	}
	// 执行线程任务 
	printf("thread %d\n", id);
}

int main() {
	std::thread threads[5];
	// 创造 5 个线程
	for (int i = 0; i < 5; ++i)
	{
		threads[i] = std::thread(printID, i);
	}

	g_ready = true;
	g_cv.notify_all();//唤醒全部线程

	for (auto &th : threads)
	{
		th.join();
	}

	return 0;
}

上面代码的输出为:

thread 3
thread 2
thread 1
thread 0
thread 4

在上面代码中,创建了 5 个线程,这些线程都试图打印自己的 ID。但是,在它们开始打印之前,它们会等待 ready 变量变为 true。 语句 g_cv.notify_all(); 负责唤醒所有等待的线程。

3.2.2 std::condition_variable::wait_for

std::condition_variable::wait_for 是一个成员函数,它允许线程等待一个特定的时间段,或者直到被其他线程唤醒,或者直到一个特定的条件满足(通过提供的谓词来检查)。这个函数返回一个表示实际等待时间的 std::chrono::duration 对象。
wait_for 的行为如下:
(1)解锁互斥量,允许其他线程锁定它。

(2)阻塞当前线程,直到以下条件之一满足:

  • 谓词(等待条件)返回 true(如果提供了的话)。
  • 超过了指定的时间段。
  • 其他线程调用了 notify_one() 或 notify_all() 方法。

(3)重新锁定互斥量。

(4)如果谓词(等待条件)被提供且返回 false,或者超时了而没有收到通知,则返回表示实际等待时间的 std::chrono::duration 对象(可能小于 预定的等待时间段,因为等待可以被提前唤醒)。
如下为样例代码:

#include <iostream>  
#include <thread>  
#include <mutex>  
#include <condition_variable>  

std::mutex g_mutex; // 全局互斥锁  
std::condition_variable g_cv;
bool g_ready = false;

void threadFunc()
{
	std::unique_lock<std::mutex> lock(g_mutex);
	auto timeout = std::chrono::seconds(2);

	// 使用 wait_for 等待,同时检查 ready 的状态或超时  
	bool res = g_cv.wait_for(lock, timeout, [] { return g_ready; });
	
	// 等待结果 
	std::string strRes = res ? "true" : "false";
	printf("wait_for result : %s\n",strRes.c_str());

	// 执行线程任务 
	printf("do thread task.\n"); 
}

int main() {
	std::thread t(threadFunc);
	t.join();

	return 0;
}

上面代码的输出为:

wait_for result : false
do thread task.

注意:上面代码的输出说明,即使 g_ready 始终为 false , 超过预定的等待时间后,既然可以执行后续的线程任务。如果需要在没有满足谓词(等待条件)为 true ,则可以做循环等待,如下为对应的代码修改:

while(!g_cv.wait_for(lock, timeout, [] { return g_ready; }))

3.4 std::future 和 std::async

在 C++11 中, std::future 和 std::async 是与线程和异步编程相关的两个重要组件。 std::future 是一个模板类,它提供了一种从异步操作获取结果的方式。而 std::async 是一个函数模板,它启动一个异步任务并返回一个 std::future 对象,该对象表示该任务的结果。
std::future
std::future 对象是一个占位符,它存储了某种类型的值,该值在将来的某个时间点变得可用。通常,这个值是由一个异步任务(如线程)计算得到的。你可以使用 std::future::get() 成员函数来获取这个值。如果值还没有变得可用, get() 函数会阻塞,直到值变得可用为止。
std::async
std::async 函数是一个启动异步任务的便捷方式。它接受一个可调用对象(如函数、函数指针或 Lambda 表达式)作为参数,并立即返回一个 std::future 对象。这个 std::future 对象表示异步任务的结果。
如下为样例代码:

#include <iostream>  
#include <future>  
#include <chrono>  

int computeAdd(int val1, int val2)
{
	std::this_thread::sleep_for(std::chrono::microseconds(100));
	return val1 + val2;
}

int main() {
	// 使用std::async启动一个异步任务  
	std::future<int> result(std::async(std::launch::async, computeAdd, 1, 2));

	// 在异步任务计算期间,主线程可以做其他工作  
	printf("do some other work\n");

	// 当需要异步任务的结果时,使用 std::future::get() 来获取它  
	int value = result.get(); // 这会阻塞,直到异步任务完成  

	printf("result is %d\n", value);

	return 0;
}

上面代码的输出为:

do some other work
result is 3

在上面中, computeAdd 函数被异步执行,并返回 1+ 2 的结果。主线程在异步任务执行期间可以继续执行其他工作。当需要异步任务的结果时,它调用 result.get() 来获取它。这会阻塞主线程,直到异步任务完成并返回结果。

4 线程休眠

C++11 标准库中的两个函数 std::this_thread::sleep_for 和 std::this_thread::sleep_until 用于使当前线程暂停执行一段时间。这两个函数是std::this_thread命名空间的一部分,用于控制当前线程的行为。
std::this_thread::sleep_for
这个函数使当前线程休眠至少指定的时间段。它接受一个std::chrono::duration对象作为参数,该对象表示要休眠的时间长度。

std::this_thread::sleep_for(std::chrono::seconds(1)); 			// 休眠1秒
std::this_thread::sleep_for(std::chrono::milliseconds(100));	// 休眠100毫秒

std::this_thread::sleep_until
这个函数使当前线程休眠直到指定的时间点。它接受一个 std::chrono::time_point 对象作为参数,该对象表示线程应该休眠直到的时间点。

auto now = std::chrono::system_clock::now();  
std::this_thread::sleep_until(now + std::chrono::seconds(1)); // 休眠直到从现在开始的1秒后

与 sleep_for 不同, sleep_until 允许指定一个绝对的时间点,而不是一个相对的时间段。这使得 sleep_until 在需要多次休眠或在特定时间唤醒的场景中更为有用。
在实际使用中,应该根据具体需求选择 sleep_for 或 sleep_until 。如果只需要让线程休眠一个固定的时间段,那么 sleep_for 通常更简单且更直观。但是,如果需要在特定的时间点唤醒线程,或者需要累积多个休眠时间段来达到一个特定的唤醒时间,那么 sleep_until 可能更适合。
如下为样例代码:

#include <iostream>  
#include <thread>  
#include <chrono>  
  
int main() 
{  
    // 使用 sleep_for 休眠1秒  
    std::this_thread::sleep_for(std::chrono::seconds(1));  
	
    // 使用 sleep_until 休眠直到从现在开始的2秒后  
    auto now = std::chrono::system_clock::now();  
    std::this_thread::sleep_until(now + std::chrono::seconds(2));  
  
    return 0;  
}
02-16 22:09