面试题8:什么是线程局部存储的技术
线程局部存储(Thread Local Storage,TLS)是一种存储变量的方法,这些变量在其所在的线程内是全局可访问的,但不能被其他线程访问,从而实现了变量的线程独立性。
在C++中,线程局部存储的技术通过 thread_local 关键字来实现。 thread_local 关键字允许声明一个变量,该变量的副本对于每个线程都是唯一的,每个线程都可以独立地访问和修改其自己的副本,而不会与其他线程的副本产生冲突。
使用 thread_local 关键字声明的变量是线程特定的,这意味着每个线程都有该变量的一个独立实例。当线程被创建时,它的 thread_local 变量会被初始化,而当线程结束时,这些变量会被销毁。
如下为样例代码:
cpp
#include <iostream>
#include <thread>
#include <mutex>
// 声明一个线程局部存储的整数变量
thread_local int tlsVal = 0;
std::mutex g_coutMutex;
void incrementTlsVal()
{
// 每个线程都会增加自己的 tlsVal 副本
tlsVal++;
{
std::unique_lock<std::mutex> lock(g_coutMutex);
std::cout << "thread " << std::this_thread::get_id() << " tlsVal: " << tlsVal << std::endl;
}
}
int main()
{
// 创建两个线程
std::thread t1(incrementTlsVal);
std::thread t2(incrementTlsVal);
// 等待线程完成
t1.join();
t2.join();
// 输出主线程的tls_var值,它应该仍然是0,因为它没有被修改过
std::cout << "main thread tlsVal: " << tlsVal << std::endl;
return 0;
}
上面代码的输出为:
thread 9024 tlsVal: 1
thread 2632 tlsVal: 1
main thread tlsVal: 0
在上面代码中,incrementTlsVal 函数中的 tlsVal 变量是线程局部的。当 t1 和 t2 线程调用这个函数时,它们各自都会修改自己的 tlsVal 副本,而不会相互干扰。因此,每个线程都会输出其自己增加的 tlsVal 值,而主线程中的 tlsVal 值将保持为初始值0,因为它从未被主线程修改过。
注意,thread_local 变量的初始化仅在每个线程首次访问该变量时发生,而不是在线程创建时。此外, thread_local 变量的生命周期与线程的生命周期相同,当线程结束时,这些变量会被自动销毁。
thread_local 为开发者提供了一种方便的方式来处理需要在多线程环境中保持独立状态的数据。然而,过度使用 thread_local 可能会导致内存使用效率降低,因为每个线程都有它自己的变量副本。因此,在使用时应谨慎考虑是否真的需要线程局部存储。
面试题9:std::future
和std::promise
是如何用于线程间通信
std::future 和 std::promise 是 C++ 标准库中的两个类,它们经常一起使用以实现线程间通信和同步。具体来说, std::promise 用于存储和设置某个值或异常,而 std::future 用于获取该值或异常。这种机制通常用于一个线程向另一个线程传递数据或信号。
以下是 std::future 和 std::promise 的基本用法:
创建 std::promise 对象:
首先,在一个线程中创建一个 std::promise 对象。这个对象将用于存储要传递给其他线程的值或异常。
获取 std::future 对象:
通过调用 std::promise 对象的 get_future() 成员函数来获取一个与之关联的 std::future 对象。这个 std::future 对象将被传递给需要接收数据的线程。
设置值或异常:
在第一个线程中,可以使用 std::promise 对象的 set_value() 函数来设置一个值,或者使用 set_exception() 函数来设置一个异常。这将使得与 std::promise 对象关联的 std::future 对象变为 ready 状态。
获取值或异常:
在第二个线程中,可以调用 std::future 对象的 get() 函数来获取存储的值或异常。如果 std::future 对象尚未 ready (即还没有被设置值或异常),则 get() 函数将阻塞,直到值或异常可用为止。
如下为样例代码:
cpp
#include <iostream>
#include <thread>
#include <future>
void setValueInNewThread(std::promise<int> prom)
{
// 模拟一些工作
std::this_thread::sleep_for(std::chrono::seconds(1));
// 设置值
prom.set_value(20);
}
int main() {
// 创建一个promise对象
std::promise<int> prom;
// 获取与promise关联的future对象
std::future<int> fut = prom.get_future();
// 创建一个新线程,并将promise对象移动给它
std::thread t(setValueInNewThread, std::move(prom));
// 在主线程中获取future对象中的值
try
{
int val = fut.get(); // 阻塞,直到值被设置
printf("the value is %d\n", val);
}
catch (...)
{
printf("an exception was thrown\n");
}
// 等待线程完成
t.join();
return 0;
}
上面代码的输出为:
the value is 20
在上面代码中,创建了一个新的线程 t ,并将 std::promise 对象移动给它。这个线程在一段时间后设置了一个整数值 20 。主线程则通过 std::future 对象 fut 来获取这个值,并通过 get() 函数阻塞等待,直到值被设置。一旦值被设置,主线程将打印出这个值。
注意,std::promise和std::future通常用于一次性的值传递。如果需要多次传递值或希望在不同线程之间建立一个持续的通信通道,那么可能需要考虑其他机制,如条件变量、消息队列或管道。
面试题10:死锁产生的条件是什么,如何预防死锁的出现
死锁产生的条件主要有四个:
互斥
一个资源每次只能被一个进程使用。如果此时还有其他进程请求资源,则请求者只能等待,直到占有资源的进程使用完毕释放资源。
请求和保持
一个进程因请求资源而阻塞时,对已获得的资源保持不放。
不可剥夺
进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
循环等待
在发生死锁时,必然存在一个进程-资源的环形链。即进程集合 {P0,P1,P2,...,Pn} 中的 P0 正在等待一个 P1 占用的资源; P1 正在等待 P2 占用的资源,...,Pn 正在等待已被 P0 占用的资源。
只要系统发生死锁,这些条件必然成立。因此,预防死锁的策略主要是破坏上述四个条件中的一个或多个。
为了预防死锁,可以采取以下策略:
(1)避免循环等待:确保资源请求的顺序是一致的。如果每个线程都按照相同的顺序请求资源,那么就不可能出现循环等待的情况,从而避免了死锁。
(2)一次只请求一个资源:如果可能的话,尽量让线程一次只请求一个资源。这样可以减少资源争用的可能性,从而减少了死锁的风险。
(3)使用资源层次结构:将资源组织成层次结构,并要求线程按照层次顺序请求资源。这样,即使发生了资源争用,也不会出现循环等待。
(4)使用超时机制:在尝试获取资源时设置超时时间。如果线程在超时时间内无法获取资源,则放弃该资源并尝试其他策略。
(5)使用锁顺序协议:确保所有线程都按照相同的顺序获取锁。这可以通过为每个锁分配一个唯一的标识符,并要求线程按照标识符的顺序获取锁来实现。
为了检测死锁,可以采取以下策略:
(1)使用死锁检测算法:实现一个死锁检测算法,定期检查线程和资源的状态,以确定是否存在死锁。如果检测到死锁,可以采取适当的措施来恢复,例如通过中断一个线程来解除死锁。
(2)使用资源监视器:实现一个资源监视器,跟踪每个资源的使用情况。如果资源监视器发现某个资源被多个线程同时请求并等待,那么可能存在死锁。在这种情况下,资源监视器可以采取措施来解除死锁。
(3)使用操作系统提供的死锁检测工具:一些操作系统提供了死锁检测工具,可以帮助开发人员检测和解决死锁问题。这些工具可以监视线程和资源的状态,并在检测到死锁时发出警告或采取其他措施。
需要注意的是,死锁预防和检测策略的选择取决于具体的应用场景和需求。在选择合适的策略时,需要权衡性能、复杂性和可靠性等因素。
面试题11:std::atomic<int> 与 int 的区别是什么
std::atomic 是一个模板类,它可以用于任何整数类型(如 int, long, long long 等)以及指针类型。std::atomic<int> 提供了一种在多线程环境中对 int 类型安全执行原子操作的方式。
如下为样例代码:
cpp
#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>
std::atomic<int> g_atomicNum(0); // 定义一个原子整数变量,并初始化为0
int g_num = 0; // 定义一个普通整数变量,并初始化为0
void incrementAtomicNum()
{
for (int i = 0; i < 10000000; i++)
{
// 使用原子操作增加计数器的值
g_atomicNum.fetch_add(1, std::memory_order_relaxed);
}
}
void incrementNum()
{
for (int i = 0; i < 10000000; i++)
{
g_num++;
}
}
int main()
{
std::thread t1(incrementAtomicNum); // 创建第一个线程来增加原子计数器
std::thread t2(incrementAtomicNum); // 创建第二个线程来增加原子计数器
t1.join(); // 等待第一个线程完成
t2.join(); // 等待第二个线程完成
// 输出最终计数器的值,应该是20000000
printf("atomic number: %d\n", g_atomicNum.load());
std::thread t3(incrementNum); // 创建第三个线程来增加普通计数器
std::thread t4(incrementNum); // 创建第四个线程来增加普通计数器
t3.join(); // 等待第三个线程完成
t4.join(); // 等待第四个线程完成
// 输出最终计数器的值,结果是不确定的
printf("normal number: %d\n", g_num);
return 0;
}
上面代码的输出为:
atomic number: 20000000
normal number: 13480371
普通整型变量 g_num 在两个线程中的累加之所以是一个未知量,是因为在增加的过程中,可能会发生线程间的切换。这会导致数据竞争,最终的 g_num 值可能不是预期的 20000000,而是小于这个值。
上面代码实际上是一个原子操作的样例,C++ 中的原子操作是指那些在多线程环境中执行时不可被其他线程中断的操作。这些操作要么完全执行,要么完全不执行,不会出现执行到一半被其他线程打断的情况。原子操作是并发编程中的一个重要概念,用于确保数据在多线程环境中的一致性。
在 C++ 中,原子操作通过 std::atomic 模板类来实现。 std::atomic 提供了一组成员函数,用于执行原子操作,如加载、存储、交换、比较并交换、加减等。这些操作都是针对单个原子类型的变量进行的,例如 std::atomic<int> 就是对 int 类型的变量进行原子操作。
原子操作的重要性在于它们可以防止数据竞争( data race )的发生。数据竞争是指两个或多个线程在没有同步的情况下访问同一内存位置,并且至少有一个是写入操作。如果没有原子性保证,数据竞争可能导致不可预测的结果,因为线程之间的操作可能会相互干扰。
通过使用 std::atomic ,开发者可以确保对特定变量的访问和修改是原子的,从而避免数据竞争。例如,当多个线程需要共享和修改一个计数器时,可以使用 std::atomic<int> 来确保计数器的增减操作是原子的,从而避免出现计数错误的情况。
需要注意的是,虽然 std::atomic 提供了原子操作的保证,但它并不能解决所有并发编程中的问题。例如,对于复合操作(由多个原子操作组成)的原子性,仍然需要使用其他同步机制(如互斥量、条件变量等)来确保线程安全。此外, std::atomic 也只能保证对单个原子变量的操作是原子的,对于涉及多个原子变量的复合操作,仍然需要谨慎处理。
面试题12:如何理解线程安全
线程安全( Thread Safety )是并发编程中的一个重要概念,它涉及到多个线程同时访问和修改共享数据时如何确保数据的一致性和程序的正确性。线程安全主要关注的是在并发环境下,代码的执行不会因为多个线程的交错执行而导致错误或不可预期的行为。
在C++中,线程安全通常涉及到以下几个方面:
原子操作
原子操作是指一个操作在执行过程中不会被其他线程打断的操作。在多线程环境中,如果一个操作不是原子的,那么它可能会在执行过程中被其他线程打断,导致数据的不一致。C++提供了std::atomic模板类来支持原子操作。
互斥锁( Mutexes )
互斥锁是一种同步机制,用于保护共享资源,确保同一时间只有一个线程可以访问这些资源。当线程尝试获取一个已经被其他线程持有的锁时,该线程将被阻塞,直到锁被释放。C++中可以使用std::mutex来实现互斥锁。
条件变量( Condition Variables )
条件变量通常与互斥锁一起使用,用于在多个线程之间传递信号。一个线程可以在条件变量上等待,直到另一个线程发出通知,表示某个条件已经满足。 C++ 中可以使用 std::condition_variable 来实现条件变量。
线程安全的容器和函数
C++标准库中的某些容器和函数是线程安全的,可以在多个线程之间共享和访问。例如, std::vector 和 std::list 等容器在单个线程中是安全的,但在多线程环境中,如果没有适当的同步机制,它们可能会导致数据竞争。为了在多线程环境中安全地使用这些容器,可以使用前面提到的互斥锁、条件变量等同步机制。
线程局部存储( Thread-Local Storage )
线程局部存储是一种机制,允许每个线程拥有其自己的变量副本。这样,每个线程都可以独立地修改自己的变量,而不会影响到其他线程。 C++ 中可以使用 thread_local 关键字来声明线程局部变量。
总之,线程安全是并发编程中的一个重要概念,它要求开发者在编写代码时考虑到多个线程可能同时访问和修改共享数据的情况,并采取适当的同步机制来确保数据的一致性和程序的正确性。在 C++ 中,可以通过使用原子操作、互斥锁、条件变量、线程安全的容器和函数以及线程局部存储等机制来实现线程安全。