目录
一、基础概念
1、什么是线程
线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个线程就是一个“执行流”,同一进程中的多个线程之间可以并发执行。线程本身不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。
2、线程与进程的区别
- 进程是系统进行资源分配和调度的一个独立单位,而线程是进程的一部分,是CPU调度和分派的基本单位,是比进程更小的能够独立运行的基本单位。
- 一个进程至少包含两个线程,一个线程只能属于一个进程,而一个进程可以拥有多个线程。
- 同一进程下的不同的多个线程,共享父进程的地址空间,但每个线程有各自的调用栈、寄存器环境和线程本地存储。
3、多线程的优点
多线程是指从软件或者硬件上实现多个线程并发执行的技术。多线程在现代编程中非常重要,因为它具有以下优势:
- 并发性高:一个进程可创建多个线程来执行同一程序的不同部分,因此线程实现并发性更加方便。
- 资源利用最大化:通过多线程,可以更有效地使用计算机的CPU资源。在一个线程等待如I/O操作的完成时,其他线程可以继续在CPU上执行计算任务,从而减少CPU空闲时间。
- 响应性提升:在用户界面编程中,多线程可以避免耗时操作阻塞UI线程,从而保持界面的响应性
二、C++线程库
1、如何在C++中创建一个线程
在C++11及以后的版本中,可以使用头文件中的std::thread类来创建一个线程。下面是一个简单的示例,展示了如何创建一个线程来执行一个函数:
#include <iostream>
#include <thread>
void print_hello() {
std::cout << "Hello from thread " << std::this_thread::get_id() << '\n';
}
int main() {
std::thread t(print_hello); // 创建一个线程t来执行print_hello函数
t.join(); // 等待线程t执行完毕
return 0;
}
2、std::thread类及其主要方法
std::thread类提供了对线程的封装和操作。以下是该类的一些主要方法:
1.构造函数:
std::thread t(function_to_call, arg1, arg2, ...);
使用提供的函数和参数初始化一个新线程。
2.join():
t.join();
阻塞调用线程,直到线程t完成执行。如果t不是可连接的(即已经被join()调用过或者已经分离),则join()会抛出std::system_error。
3.detach():
t.detach();
将线程t从当前线程中分离,允许它独立运行。一旦线程被分离,就不能再调用join()。
4.get_id():
std::thread::id id = t.get_id();
返回线程的ID,它是一个不透明的类型,但可以安全地用于比较两个线程是否相同。
5.joinable():
if (t.joinable()) { ... }
检查线程t是否可连接(即尚未被join()或detach())。
6.swap():
std::thread a, b; a.swap(b);
交换两个线程对象的状态。
7.native_handle()
std::thread::native_handle_type handle = t.native_handle();
返回特定于实现的原生句柄。这可以用于与底层线程库(如POSIX线程)进行交互。
3、如何确保线程被正确地清理
- 使用join()方法:当你知道线程何时应该结束时,可以在主线程中调用join()等待线程执行完成。这样可以确保主线程在线程执行完所有任务后继续执行。
- 使用detach()方法:如果你不关心线程何时完成执行,可以调用detach()将其与当前线程分离。这将允许线程在后台运行,直到其完成。但是,请注意,一旦线程被分离,你就不能再调用join()了。
- 使用智能指针和std::thread的析构函数:如果你将std::thread对象存储在智能指针(如std::unique_ptr或std::shared_ptr)中,并在适当的时候删除或重置智能指针,那么当智能指针不再拥有std::thread对象时,析构函数将自动调用std::terminate()来终止未连接的线程(即既未调用join()也未调用detach()的线程)。但是,这通常不是一个好的做法,因为它会导致程序终止。
最好的做法通常是根据你的应用程序需求来选择使用join()还是detach()。如果你需要等待线程完成并获取其结果,那么使用join()。如果你不关心线程何时完成,或者你想让它在后台运行,那么使用detach()。
三、C++线程同步
1、什么是线程同步
线程同步是确保多个线程在并发访问共享资源时,能够正确地协调它们之间的执行顺序,以避免数据竞争和不一致性的重要机制。当多个线程同时访问和修改同一内存区域(即共享资源)时,如果没有适当的同步机制,就可能发生数据竞争,导致程序出现不可预测的行为和结果。
2、实现线程同步的方法
- 互斥量(Mutex):互斥量是最基本的同步原语之一,用于保护临界区(即需要互斥访问的代码段)的访问。当一个线程拥有互斥量时,其他试图获取该互斥量的线程将被阻塞,直到拥有者释放互斥量。
- 条件变量(ConditionVariable):条件变量用于线程间的同步,它允许线程在特定条件不满足时等待,并在条件满足时被唤醒。条件变量通常与互斥量一起使用,以确保在检查和修改条件时的原子性。
- 信号量(Semaphore):信号量是一个计数器,用于控制对有限资源的访问。信号量允许多个线程同时访问共享资源,但总数不能超过信号量的初始值。当线程需要访问资源时,它会尝试减少信号量的值;如果值大于0,则允许访问;如果值为0,则线程将被阻塞,直到其他线程释放资源并增加信号量的值。
- 屏障(Barrier):屏障用于确保一组线程在执行到某个点之前必须全部到达该点。这可以用于同步一组并行执行的线程,以确保它们在继续执行之前都已完成特定的任务。
3、在C++中使用互斥量(std::mutex)来保护共享资源
在C++中,可以使用头文件中的std::mutex类来保护共享资源。下面是一个简单的示例,展示了如何使用互斥量来保护一个整数计数器:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 全局互斥量
int counter = 0; // 共享资源
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx); // 自动锁定互斥量
++counter;
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final counter value: " << counter << std::endl;
return 0;
}
在这个示例中,std::lock_guard是一个RAII(Resource Acquisition Is Initialization)风格的锁定器,它在构造时自动锁定互斥量,并在析构时自动解锁。这样可以确保在increment函数执行期间,对counter的访问是互斥的。
4、死锁
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
死锁的原理是,多个进程或线程之间由于彼此互相持有对方所需资源而无法继续执行。死锁发生的原因通常是由于多个进程同时请求资源,但由于资源分配不当或者竞争条件等问题,导致彼此之间陷入僵局无法继续执行。
死锁产生的条件包括:
- 互斥条件:进程对所需的资源具有排他性,即一次只能有一个进程使用该资源。
- 请求和保持条件:进程在获取某一资源的同时继续请求其他资源。
- 不可剥夺条件:已经分配给进程的资源不能被其他进程抢占,只能由持有资源的进程自己释放。
- 循环等待条件:存在一个进程资源的循环链,使得每个进程都在等待下一个进程所持有的资源。
5、如何避免死锁
- 保持锁的粒度尽量小:只锁定必要的资源,并在尽可能短的时间内持有锁。
- 避免嵌套锁:尽量不要在一个已经持有锁的线程中请求另一个锁,因为这可能导致锁的顺序问题。
- 使用超时等待:在尝试获取锁时使用超时,以避免永久等待。
- 设计良好的锁定顺序:如果必须嵌套锁,确保所有线程都按照相同的顺序请求锁。
- 使用死锁检测和恢复机制:在程序中实现死锁检测和恢复机制,以便在发生死锁时能够自动解
6、常见的死锁场景
- 两个线程分别持有各自的锁,并试图获取对方的锁。
- 嵌套锁中锁的顺序不一致。
- 一个线程在等待一个永远不会释放的锁。
- 在持有锁的情况下调用了一个可能导致阻塞的函数(如I/O操作)。
四、C++线程安全
线程安全是一个编程概念,指的是在多线程环境下,代码的执行结果不会因为多线程的并发执行而产生错误或不可预期的结果。当一个类、方法或函数在并发环境中被多个线程同时访问时,如果它的行为是正确的,那么这个类、方法或函数就被称为线程安全的。
线程安全之所以重要,是因为在现代多核CPU的计算机系统中,多线程编程是提高程序性能、响应性和利用系统资源的重要手段。然而,多线程编程也带来了数据竞争、死锁、活锁和饥饿等问题,这些问题可能导致程序崩溃、数据损坏或其他不可预测的行为。因此,编写线程安全的代码是确保程序在多线程环境中正确运行的关键。
下面是一个简单的例子,说明如何编写线程安全的代码:
假设我们有一个简单的计数器类,用于统计某个事件的次数。在多线程环境下,这个计数器可能会被多个线程同时访问和修改。如果我们不采取任何线程安全措施,就可能会导致数据竞争和不正确的计数结果。
// 非线程安全的计数器类
class Counter {
public:
int count;
Counter() : count(0) {}
void increment() {
count++; // 这里可能发生数据竞争
}
int getCount() {
return count; // 这里也可能发生数据竞争
}
};
// 线程安全的计数器类
#include <mutex>
class ThreadSafeCounter {
public:
int count;
std::mutex mtx; // 引入互斥锁
ThreadSafeCounter() : count(0) {}
void increment() {
std::lock_guard<std::mutex> lock(mtx); // 自动加锁和解锁
count++;
}
int getCount() {
std::lock_guard<std::mutex> lock(mtx); // 自动加锁和解锁
return count;
}
};
在上面的例子中,我们使用了C++标准库中的std::mutex来实现线程安全。在ThreadSafeCounter类中,我们为count成员变量添加了一个互斥锁mtx。在访问和修改count时,我们使用std::lock_guard来自动加锁和解锁,确保同一时间只有一个线程可以访问count。这样就避免了数据竞争,实现了线程安全。
五、生产者-消费者问题
生产者-消费者问题是一个经典的并发编程问题,其中生产者生成数据项,并将它们放入一个缓冲区,而消费者从缓冲区中取出数据项并处理它们。这个问题的主要挑战是如何确保生产者和消费者之间的同步,以避免缓冲区溢出或下溢。
以下是使用C++11及以后版本的线程库来解决生产者-消费者问题的一个简单示例:
#include <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
std::queue<int> buffer;
std::mutex mtx;
std::condition_variable cond_var;
bool stop = false;
void producer() {
while (!stop) {
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟生产延迟
std::unique_lock<std::mutex> lock(mtx);
buffer.push(rand()); // 生产一个随机整数
lock.unlock();
cond_var.notify_one(); // 通知消费者
}
}
void consumer() {
while (!stop || !buffer.empty()) {
std::unique_lock<std::mutex> lock(mtx);
cond_var.wait(lock, []{ return !buffer.empty() || stop; }); // 等待数据或停止信号
if (!stop && !buffer.empty()) {
int value = buffer.front();
buffer.pop();
lock.unlock();
std::cout << "Consumed: " << value << std::endl; // 处理数据
}
}
}
int main() {
std::thread prod(producer);
std::thread cons(consumer);
// 让生产者和消费者运行一段时间
std::this_thread::sleep_for(std::chrono::seconds(5));
stop = true; // 停止信号
cond_var.notify_all(); // 通知所有等待的线程
prod.join();
cons.join();
return 0;
}
这个示例中,生产者线程生产随机整数并将它们放入缓冲区,而消费者线程从缓冲区中取出整数并打印它们。我们使用了一个互斥锁来保护对缓冲区的访问,并使用了一个条件变量来实现生产者和消费者之间的同步。