突破编程_C++_C++11新特性(多线程编程的基础知识)

1 多线程编程基础

1.1 多线程编程概述

在 C++11 之前的版本中,C++ 标准库并没有直接支持多线程编程,这导致开发者在编写多线程程序时通常需要依赖于平台特定的 API 或者第三方库。然而,随着计算机硬件的发展和多核处理器的普及,多线程编程变得越来越重要,因为它能够有效利用多核处理器的优势,提高程序的执行效率。

C++11 标准引入了完整的线程库,使得 C++ 开发者能够以一种标准、跨平台的方式编写多线程程序。这一改变极大地简化了多线程编程的复杂性,并使得 C++ 成为一种更加适合编写高性能、高并发应用程序的语言。

C++11 的线程库提供了一组类和函数,用于创建和管理线程,以及实现线程间的同步和通信。其中最核心的类是 std::thread,它代表了一个执行线程。通过创建 std::thread 对象并传递一个可调用对象(如函数、函数对象或 Lambda 表达式)作为参数,就可以启动一个新的线程来执行该可调用对象。

除了线程的创建和管理,C++11 的线程库还提供了一系列用于线程同步的机制。这些机制包括互斥锁(std::mutex)、条件变量(std::condition_variable)和原子操作(std::atomic)等。互斥锁用于保护共享数据,防止多个线程同时访问导致数据竞争或不一致。条件变量用于在多个线程之间传递信号,实现线程的协调与同步。原子操作则提供了一种在线程间安全地操作共享数据的方式,而无需使用锁。

此外,C++11 的线程库还考虑了线程安全问题。它定义了一套内存模型,规范了线程间的可见性和顺序性,从而避免了数据竞争和死锁等线程安全问题。开发者在编写多线程程序时,需要遵循这些规范,以确保程序的正确性和稳定性。

通过利用 C++11 的线程库,开发者可以编写出高效、可移植的多线程程序,充分利用多核处理器的优势,提高程序的执行效率。同时,线程库也提供了一套完整的同步机制,使得开发者能够轻松地实现线程间的协调与通信,避免线程安全问题。

1.2 进程与线程的区别

进程与线程是两个核心概念,它们在操作系统中各自扮演着不同的角色,具有不同的特性和用途。下面将详细讲解它们之间的主要区别:

(1)资源分配与调度:

  • 进程:进程是操作系统资源分配的基本单位。每个进程都有自己独立的代码和数据空间,以及系统为其分配的内存和其他资源。进程之间的切换通常涉及较大的开销,因为操作系统需要保存和恢复进程的上下文信息。
  • 线程:线程是 CPU 调度和分派的基本单位。线程共享所属进程的地址空间和资源,包括代码段、数据段和打开的文件等。因此,线程之间切换的开销较小,因为它们已经共享了大部分的环境。但线程本身并没有独立的内存空间,它使用的资源都来自其所属进程。

(2)独立性与通信:

  • 进程:进程具有较高的独立性。一个进程崩溃后,在保护模式下不会对其它进程产生影响。进程间的通信(IPC,Inter-Process Communication)通常需要通过操作系统提供的机制,如管道、信号、消息队列、共享内存等,这些机制通常较为复杂且开销较大。
  • 线程:线程是进程中的一个执行流,它们共享进程的地址空间和其他资源。因此,线程间的通信相对简单,可以直接读写共享内存中的数据,或者使用线程特有的同步机制(如互斥锁、条件变量等)来协调执行。

(3)并发执行:

  • 进程:多个进程可以同时执行,每个进程拥有自己独立的执行环境,适用于需要高度隔离的并发任务。
  • 线程:线程是在同一个进程内并发执行的,它们共享进程的资源,因此适用于需要共享数据且需要高并发性能的场景。

(4)健壮性:

  • 进程:由于进程具有独立的地址空间,一个进程的崩溃通常不会影响到其他进程,因此多进程的程序相对更加健壮。
  • 线程:由于线程共享进程的地址空间,一个线程的错误操作可能影响到其他线程,甚至导致整个进程的崩溃,因此多线程的程序需要更加谨慎地处理同步和共享数据的问题。

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

C++11 多线程编程带来了显著的优势,同时也伴随着一些挑战。如下是 C++11 多线程编程优势与挑战的具体介绍。

(1)优势:

  • 充分利用多核处理器资源:随着硬件技术的不断发展,现代计算机普遍配备了多核处理器。多线程编程能够使得程序中的不同任务同时运行在不同的核心上,从而充分利用多核处理器的计算能力,提高程序的执行效率。

  • 提高程序响应性:多线程允许程序在执行一个耗时任务的同时,继续处理其他任务。这对于需要同时处理多个用户请求或事件的程序(如服务器应用程序、图形用户界面等)尤为重要,可以显著提高程序的响应性和用户体验。

  • 简化并发编程:C++11 标准库提供了完整的线程支持,包括线程创建、同步机制等,使得开发者能够以一种标准、统一的方式编写多线程程序。这降低了并发编程的复杂度,提高了开发效率。

  • 更好的性能优化:多线程编程允许开发者更细粒度地控制任务的执行顺序和并行度,从而更容易实现性能优化。通过合理地分配任务到不同的线程上,可以减少不必要的等待和阻塞,提高程序的吞吐量。

(2)挑战:

  • 线程同步与通信:多线程编程中最常见的挑战之一是线程同步与通信。多个线程同时访问共享资源时,如果没有正确的同步机制,可能会导致数据竞争、死锁等问题。因此,开发者需要仔细设计线程间的同步策略,以确保数据的一致性和程序的正确性。

  • 复杂性增加:多线程编程增加了程序的复杂性。线程间的交互、共享数据的访问以及错误处理等都需要开发者仔细考虑。同时,多线程程序也更难以调试和测试,因为线程的执行顺序和并发行为可能因运行环境的不同而有所差异。

  • 资源竞争与开销:多线程程序中的每个线程都需要占用一定的系统资源(如内存、CPU 时间片等)。当线程数量过多时,可能会导致资源不足或过度竞争,从而降低程序的性能。此外,线程的创建、销毁和上下文切换等操作也会带来一定的开销。

  • 可移植性与兼容性:虽然 C++11 标准库提供了线程支持,但不同编译器和操作系统对线程的实现可能存在差异。这可能导致多线程程序在不同平台上的行为不一致,增加了可移植性和兼容性的挑战。开发者需要关注不同平台的特性,并进行适当的适配和测试。

2 C++11 线程库简介

(1)发展背景

随着计算机技术的不断进步,多核处理器已成为主流,使得并行计算成为提高程序性能的关键手段。然而,早期的C++标准并未直接支持多线程编程,这限制了并行计算在C++程序中的广泛应用。为了充分利用多核处理器资源,提高程序的执行效率,C++标准委员会开始着手设计并引入线程库。

此外,跨平台与标准化的需求也推动了C++11线程库的发展。不同操作系统和平台提供了各自的线程API和机制,这使得开发者在编写跨平台的多线程程序时面临了巨大的挑战。因此,急需一种标准化的线程库,使得开发者能够以一种统一、可移植的方式编写多线程程序。

(2)主要组件

C++11 线程库提供了一套完整的类和函数,用于创建和管理线程,以及实现线程间的同步和通信。以下是其主要组件的简介:

std::thread
std::thread 是线程库的核心类,用于表示一个执行线程。通过创建一个 std::thread 对象并传递一个可调用对象(如函数、函数对象或Lambda表达式)作为参数,可以启动一个新的线程来执行该可调用对象。这个类提供了一系列成员函数来管理线程的生命周期,如 join()(等待线程结束)和detach()(分离线程,使其在后台运行)。

线程同步机制
线程库提供了一系列同步机制,用于保护共享数据并协调线程间的执行顺序。这些机制包括:

  • std::mutex:互斥锁,用于保护共享数据,防止多个线程同时访问。
  • std::lock_guard和std::unique_lock:锁类型对象,用于自动管理互斥锁的生命周期,简化锁的使用。
  • std::condition_variable:条件变量,用于在线程间传递信号,实现线程的协调与同步。
  • std::atomic:原子操作类,提供了一组线程安全的操作,用于在线程间安全地操作共享数据。

线程局部存储
thread_local 关键字用于声明线程局部存储的变量。这些变量在每个线程中都有自己的副本,避免了线程间的数据竞争。

其他辅助类和函数
线程库还提供了一些其他辅助类和函数,如 std::this_thread(提供当前线程的操作,如 yield()和 sleep_for()),以及用于获取线程 ID 和硬件并发性的函数。

3 线程的创建与管理

3.1 线程的创建与启动

(1)创建与启动的主要步骤

包含头文件:首先,需要包含<thread>头文件,这个头文件包含了 std::thread 类和其他与线程相关的定义。

#include <thread>

定义线程函数:需要定义一个线程函数,这个函数将在新的线程中执行。线程函数可以是普通的函数、静态成员函数、Lambda 表达式或者函数对象。

创建线程对象:使用 std::thread 类创建一个线程对象,并将线程函数作为构造函数的参数。

启动线程:当 std::thread 对象被创建时,线程即开始执行。不需要额外的启动步骤。

(2)示例

下面是一个简单的 C++11 线程创建与启动的示例:

#include <iostream>  
#include <thread>  
  
// 线程函数  
void print_hello_from_thread(int thread_id) { 
	printf("Hello from thread %d!\n", thread_id); 
    // 重点注意:这里没有使用 std::cout,这是由于	 std::cout 不是线程安全,会出现非预期打印结果。
}  
  
int main() 
{  
    // 创建两个线程对象  
    std::thread t1(print_hello_from_thread, 1);  
    std::thread t2(print_hello_from_thread, 2);  
  
    // 等待线程完成  
    t1.join();  
    t2.join();  
  
    return 0;  
}

上面代码的输出为:

Hello from thread 1!
Hello from thread 2!

在这个示例中:

  • print_hello_from_thread 函数是一个线程函数,它接收一个整数参数 thread_id,并输出一条消息。
  • 在 main 函数中,创建了两个 std::thread 对象 t1 和 t2,并将 print_hello_from_thread 函数作为它们的构造函数参数。同时,还传递了线程 ID 作为参数。
  • 创建线程对象时,线程即开始执行。
  • t1.join() 和 t2.join() 确保主线程等待这两个线程完成执行。如果不调用 join 或 detach,程序会在 main 函数返回时终止,可能会导致线程未正常完成。

(3)注意事项

  • 确保线程函数是线程安全的,特别是当它们访问共享数据时。
  • 如果线程函数不需要任何参数,可以在创建线程对象时直接传递函数名。
  • 使用 std::thread 时,请确保线程在结束时能够被正确管理,避免资源泄露。
  • std::thread 对象是不可复制的,但它是可移动的,这意味着可以使用 std::move 来将线程所有权从一个对象转移到另一个对象。

3.2 线程的等待与分离

线程的等待与分离是线程管理中重要的两个概念。等待(join)指的是主线程或其他线程等待一个线程结束执行,而分离(detach)则是允许线程在后台运行,其结束不会影响其他线程的继续执行。下面我将详细讲解这两个概念,并给出相应的示例。

(1)线程的等待(join)

当一个线程被创建后,默认情况下它是可连接的(joinable)。这意味着在线程结束时,必须有一个线程调用其join()成员函数,否则程序可能会产生未定义的行为(通常是崩溃)。join()函数会阻塞当前线程,直到被调用的线程结束执行。

示例:

#include <iostream>  
#include <thread>  
  
void threadFunction() {  
    printf("Hello from thread!\n");  
}  
  
int main() 
{  
    std::thread t(threadFunction);  
  
    // 等待线程t结束  
    t.join();  
  
    std::cout << "Main thread continues after thread t has finished.\n";  
  
    return 0;  
}

上面代码的输出为:

Hello from thread!
Main thread continues after thread t has finished.

在上面的示例中,t.join() 确保主线程等待 t 线程执行完毕后再继续执行。如果省略 t.join(),则主线程可能在 t 线程完成之前就结束,这通常会导致程序崩溃,因为操作系统会试图清理一个还在运行的线程。

(2)线程的分离(detach)

线程的分离是另一种管理线程生命周期的方式。当一个线程被分离后,它会在后台运行,其结束不会阻塞其他线程。一旦线程被分离,就不能再调用其 join() 成员函数了,否则会导致程序崩溃。

示例:

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

void threadFunction() {
	printf("Hello from detached thread!\n");
	std::this_thread::sleep_for(std::chrono::seconds(2));
	printf("Detached thread is finishing.\n");
}

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

	// 分离线程t  
	t.detach();

	std::cout << "Main thread continues without waiting for the detached thread.\n";

	// 主线程休眠3秒,以便观察分离线程的输出  
	std::this_thread::sleep_for(std::chrono::seconds(3));

	return 0;
}

上面代码的输出为:

Hello from detached thread!
Main thread continues without waiting for the detached thread.
Detached thread is finishing.

上面的示例创建了一个线程 t,并立即调用 t.detach() 来分离它。这意味着主线程不会等待 t 线程结束,而是继续执行。t 线程会在后台运行,并输出其消息。主线程休眠 3 秒是为了确保可以看到分离线程的输出,否则主线程可能在分离线程完成之前就已经结束了。

(3)注意事项

  • 一旦线程被分离,你就不能再连接它了。试图对一个已经分离的线程调用 join() 会导致程序崩溃。
  • 分离线程的好处是它们不会阻塞主线程,使得主线程可以继续执行其他任务。然而,这也意味着你需要格外小心管理共享资源,以避免数据竞争和其他并发问题。
  • 在某些情况下,如果线程函数执行完成后立即退出程序,可能会导致程序崩溃,因为操作系统可能试图清理仍在运行的线程。确保在程序结束前,所有线程都已经正确结束或分离。

3.3 线程 ID 的获取

在 C++11 中,可以通过 std::thread 类提供的成员函数 get_id() 来获取线程的 ID。这个函数返回一个 std::thread::id 类型的对象,该对象可以唯一标识一个线程。

std::thread::id 类型是一个轻量级的可复制的类型,它可以用作键值类型在容器中存储,也可以用于比较两个线程 ID 是否相等。

下面是一个简单的示例,展示了如何获取和打印线程的 ID:

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

std::mutex g_printMutex;

void print_thread_id(const std::string& message) {
	// 提供了线程安全的 std::cout
	std::unique_lock<std::mutex> lock(g_printMutex);

	// 获取当前线程的ID  
	std::thread::id this_id = std::this_thread::get_id();

	// 输出线程ID和消息
	std::cout << message << " from thread " << this_id << '\n';
}

int main() 
{
	// 创建并启动两个线程  
	std::thread t1(print_thread_id, "Hello");
	std::thread t2(print_thread_id, "Goodbye");

	// 等待线程完成  
	t1.join();
	t2.join();

	// 获取并打印主线程的ID  
	std::thread::id main_thread_id = std::this_thread::get_id();
	std::cout << "Main thread ID: " << main_thread_id << '\n';

	return 0;
}

上面代码的输出为:

Hello from thread 10100
Goodbye from thread 2436
Main thread ID: 10000

在这个示例中:

  • print_thread_id 函数接受一个字符串参数,并打印出该字符串和当前线程的 ID。
  • std::this_thread::get_id() 函数用于获取当前线程的 ID。在 print_thread_id 函数中,它被用来获取调用它的线程的 ID。
  • 在 main 函数中,创建了两个线程 t1 和 t2,并将 print_thread_id 函数作为它们的入口点。每个线程都将打印出自己的 ID 和一条消息。
  • 在 main 函数的最后,再次使用 std::this_thread::get_id() 来获取主线程的 ID,并将其打印出来。
  • std::thread::id 对象可以直接输出到流中,因此可以直接使用 std::cout 来打印它们。它们还重载了 operator== 和 operator!=,用于比较两个线程 ID 是否相等。

注意:线程 ID 通常只在程序的生命周期内是唯一的。不同的程序实例或者同一个程序的不同运行实例中的线程 ID 可能不是唯一的。此外,线程 ID 不保证与任何底层操作系统的线程标识符相对应。它们只是 C++ 运行时用于区分线程的内部标识符。

3.4 线程的局部存储与数据竞争

在 C++11 中,多线程编程引入了新的复杂性和挑战,特别是当多个线程需要访问和修改共享数据时。为了避免数据竞争(data race)和其他并发问题,C++11 提供了一些机制,包括线程局部存储(Thread-Local Storage, TLS)和数据同步原语(如互斥锁和原子操作)。

(1)线程局部存储(Thread-Local Storage, TLS)

线程局部存储允许每个线程拥有其自己的数据副本,从而避免了数据竞争。在 C++11 中,可以使用 thread_local 关键字来声明线程局部变量。

示例:

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

// 声明一个线程局部变量  
thread_local std::vector<int> tls_vec;

void fill_vector() {
	// 每个线程都有自己的tls_vec副本  
	for (int i = 0; i < 10; ++i) {
		tls_vec.push_back(i);
	}

	// 打印当前线程的tls_vec内容  
	for (int i : tls_vec) {
		printf("%d ",i);
	}
	printf("\n");
}

int main() 
{
	std::thread t1(fill_vector);
	t1.join();

	std::thread t2(fill_vector);
	t2.join();

	std::cout << "tls_vec size is " << tls_vec.size() << std::endl;

	return 0;
}

上面代码的输出为:

0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9
tls_vec size is 0

在上面的示例中,tls_vec 是一个线程局部变量,这意味着每个线程都有它自己的 tls_vec 副本。fill_vector 函数填充每个线程的 tls_vec,而 print_vector 函数打印当前线程的 tls_vec 内容。因此,尽管有两个线程在并行执行,但它们不会共享或修改同一个 tls_vec 实例,从而避免了数据竞争。

(2)数据竞争(Data Race)

数据竞争发生在两个或更多线程没有正确同步的情况下访问和修改同一内存位置时。这可能导致未定义的行为,因为编译器和处理器可能会重新排序读写操作以优化性能。

为了避免数据竞争,可以使用互斥锁(如std::mutex)或原子操作来同步对共享数据的访问。

示例(使用互斥锁避免数据竞争):

#include <iostream>  
#include <thread>  
#include <vector>  
#include <mutex>  
  
// 声明一个全局的互斥锁  
std::mutex mtx;  
  
// 声明一个共享的向量  
std::vector<int> shared_vec;  
  
void fill_vector(int start) {  
    // 锁定互斥锁以访问共享数据  
    std::lock_guard<std::mutex> lock(mtx);  
    for (int i = start; i < start + 10; ++i) {  
        shared_vec.push_back(i);  
    }  
}  
  
void print_vector() {  
    // 锁定互斥锁以访问共享数据  
    std::lock_guard<std::mutex> lock(mtx);  
    for (int i : shared_vec) {  
        std::cout << i << ' ';  
    }  
    std::cout << '\n';  
}  
  
int main() 
{  
    std::thread t1(fill_vector, 0);  
    std::thread t2(fill_vector, 10);  
  
    t1.join();  
    t2.join();  
  
    // 打印共享向量内容  
    std::thread t3(print_vector);  
    t3.join();  
  
    return 0;  
}

上面代码的输出为:

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

在这个例子中,shared_vec 是一个共享的向量,而 mtx 是一个互斥锁,用于保护对 shared_vec 的访问。fill_vector 函数使用 std::lock_guard 来自动管理锁的生命周期,确保在函数执行期间锁是保持的。这防止了两个线程同时修改 shared_vec,从而避免了数据竞争。

(3)注意事项

  • 线程局部存储是解决数据竞争问题的一种方法,但不是唯一的方法。它适用于那些每个线程都需要独立数据副本的情况。
  • 使用互斥锁或其他同步原语时,要确保锁的范围尽可能小,以避免不必要的性能开销和死锁风险。过度锁定或长时间持有锁可能会导致性能瓶颈或死锁情况。
  • std::lock_guard 和 std::unique_lock 是 RAII(Resource Acquisition Is Initialization)风格的锁包装器,它们会在构造时自动获取锁,并在析构时自动释放锁。这有助于确保锁的正确管理,即使在异常情况下也能保证锁的释放。
  • 原子操作是另一种同步机制,它们允许无锁访问共享数据。C++11 提供了 std::atomic 模板类来支持原子操作。原子操作通常比互斥锁具有更低的开销,但只适用于简单的数据类型,并且对于复杂的数据结构或算法可能不够充分。
  • 在编写多线程代码时,务必仔细考虑数据的共享和访问模式,并选择合适的同步机制来避免数据竞争和其他并发问题。
  • 25
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值