C++ std::mutex 互斥锁

std::mutex 是 C++11 引入的用于 多线程同步 的类,它提供了 互斥锁(mutex)机制,确保同一时刻只有一个线程能够访问某个共享资源,从而防止多个线程同时修改共享数据时引发 数据竞争 问题。

1. 问题场景

在并发编程中,如果多个线程同时读写共享资源而没有合适的同步机制,就可能发生数据竞争,导致结果不可预测。

#if 1
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<thread>

void thread_function(int& num)
{
	for (int i = 0; i < 100000; ++i)
	{
		++num;
	}
}

void test()
{
	int num = 0;

	std::thread t1(thread_function, std::ref(num));
	std::thread t2(thread_function, std::ref(num));

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

	std::cout << "num = " << num << std::endl;
}


int main()
{
	test();
	return EXIT_SUCCESS;
}
#endif

对于上述代码:

  • 预期结果:每个线程会对 num 进行 10,0000 次自增,总共应该是 20,0000
  • 实际结果:由于并发执行,num 的最终值可能小于 200,000,具体数值是不可预测的

原因是因为 ++num 并不是原子操作,它包含三个步骤:

  • 读取 num 的值
  • num 加 1
  • 将新值写回 num

假设 num 当前值为 10,两个线程几乎同时执行 ++num

  1. 线程 A 和线程 B 都读取了 num 的值(都得到 10)
  2. 线程 A 将 num 增加 1,写回 11
  3. 线程 B 也将 num 增加 1,写回 11

最终,num 的值为 11,而不是预期的 12,因为两个线程的更新相互覆盖了。这是典型的资源竞争竞争问题导致的。

2. 解决思路

解决思路:使用互斥锁 std::mutex 确保每次只有一个线程修改 num,避免多个线程同时修改共享资源。

#if 1
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<mutex>
#include<thread>

std::mutex mtx;

#if 0
void thread_function(int& num)
{
	for (int i = 0; i < 100000; ++i)
	{
		// mtx.lock() 会尝试获取互斥锁。
		// 如果当前线程能获取到锁,它会继续执行后续代码。
		// 如果锁已经被其他线程持有,当前线程会被阻塞,直到锁被释放。
		mtx.lock();
		++num;
		// mtx.unlock() 用于释放互斥锁,使得其他线程可以获得该锁。
		// 当前线程调用 unlock() 时,锁被释放,系统会唤醒在此锁上阻塞的线程,并让它们争取获得锁。
		// 如果多个线程等待该锁,系统会根据调度策略决定哪个线程获得锁。
		mtx.unlock();
	}
}
#else
void thread_function(int& num)
{
	for (int i = 0; i < 100000; ++i)
	{
		while (true)
		{
			// mtx.try_lock() 尝试非阻塞地获取锁。
			// 如果锁已被其他线程占用,则立即返回 false,而不阻塞当前线程。
			// 此时,线程可以进行其他任务,而不是一直等待锁的释放。
			// 这种方式避免了阻塞等待,使得线程能够更灵活地处理其他任务。
			if (mtx.try_lock())
			{
				++num;
				mtx.unlock();
				break;
			}
			else
			{
				std::cout << std::this_thread::get_id() << " 真遗憾,没有抢到锁,干点别的事情!" << std::endl;
			}
			
			// 等待一段时间,再去尝试获得锁
			std::this_thread::sleep_for(std::chrono::milliseconds(100));
		}
		
	}
}
#endif

// 最后:
// 1. 使用 lock() 的方式会使线程阻塞,直到获取到锁,适合当你希望线程一直等待直到能成功获得锁时。
// 2. 使用 try_lock() 的方式允许线程不阻塞,适合当你希望线程能在锁未获取到时去做其他任务而不是一直等待。

void test()
{
	int num = 0;

	std::thread t1(thread_function, std::ref(num));
	std::thread t2(thread_function, std::ref(num));

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

	std::cout << "num = " << num << std::endl;
}


int main()
{
	test();
	return EXIT_SUCCESS;
}
#endif

3. 锁的开销

在多线程环境中,加锁(获取锁)和解锁(释放锁)会带来一定的性能开销。具体而言,当一个线程由于未能获得锁而被阻塞时,操作系统需要停止该线程的执行,并调度其他线程。此时,操作系统需要保存该线程的状态,进行线程上下文切换。当线程最终获得锁时,操作系统需要恢复该线程的状态,以便继续执行。这一过程中,线程状态的保存和恢复,以及线程切换和调度,会增加额外的开销。频繁的加锁和解锁操作会导致线程切换频繁,从而影响系统的性能。

简言之,当程序中涉及到频繁的加锁和解锁时,会引入额外的开销。为了降低这种影响,通常需要控制加锁的粒度。

锁粒度的选择

通常,锁的粒度越大,加锁和解锁的次数越少,但并行性可能会较差;而锁的粒度越小,虽然能提升并行性,但加锁和解锁的频率会更高,带来的性能开销也会增加。我们需要根据实际情况做权衡。

示例代码对比:

#if 0
	for (int i = 0; i < 1000000; ++i)
	{
		// 锁了一行代码
		mtx.lock();
		++num;
		mtx.unlock();
	}
#else
	// 锁了三行代码
	mtx.lock();
	for (int i = 0; i < 1000000; ++i)
	{
		++num;
	}
	mtx.unlock();
#endif

在这个示例中:

  • 前者:每次对num的更新都加锁、解锁一次。加锁解锁的次数更多,会导致更多的线程调度和上下文切换,性能开销较大。然而,这样的实现能够更好地支持并行性,多个线程可以在不同的循环迭代中并行执行。
  • 后者:将整个循环的代码块锁住,只加锁一次,减少了加锁解锁的次数,从而减少了线程调度和上下文切换的开销。尽管如此,由于整个循环期间只有一个线程能访问共享数据,其他线程的并行性会受到影响,因此并行性较差。

锁粒度指的是在多线程程序中,锁所涵盖的代码区域或资源的范围。粒度大小的选择通常要根据以下几个方面进行权衡:

线程数量的考虑

  • 少量线程:如果程序中运行的线程数量较少,线程之间的竞争不激烈,那么可以使用较粗的锁粒度(例如将多个操作放在同一个临界区内)。这样可以减少加锁和解锁的频率,降低性能开销,而不至于显著影响程序的并发性。
  • 大量线程:当程序中有大量线程并行工作时,使用粗粒度锁可能会导致性能瓶颈,因为多个线程需要竞争同一把锁,导致大量线程被阻塞,进而降低并发性能。在这种情况下,使用细粒度锁(即将锁的范围限制在更小的代码区域)可以减少锁竞争,提高并行度,从而提升系统性能。

资源数量与访问频率的考虑

  • 资源少且访问不频繁:当需要加锁的资源较少,且这些资源不被频繁访问时,使用粗粒度锁通常是合适的。此时加锁的代价相对较小,而且可以有效避免锁的管理和调度开销。
  • 资源多且并发访问量大:如果程序涉及多个资源,且这些资源被多个线程频繁访问,使用粗粒度锁会造成较大的性能瓶颈。细粒度锁能够让不同线程并行访问不同的资源,减少锁竞争和阻塞,从而提升性能。

未经允许不得转载:一亩三分地 » C++ std::mutex 互斥锁
评论 (0)

2 + 9 =