跟我学C++中级篇——std::shared_lock

一、读写锁

有过多语言编程的开发人员,往往在别的语言中(如Java等)看到过读写锁,它特别适合于在写少读多的情况下。一般在互联网的应用场景中,这种读非常多,写很少的场景非常普遍,所以读写锁其实应用非常多。
在C++中,原来没有这种所谓读写锁之说,只有轻量级锁和重量级锁。虽然可以通过这些锁来模拟实现读写锁,但毕竟不如原生库中带着好用。STL的发展就是要与时俱进,也要看到实际的应用需求,所以在C++14中推出了std::shared_lock。
std::shared_lock是一个通用的共享互斥体的封装,它允许延迟锁定、定时锁定和锁所有权的转移。其中使用的共享互斥体类似std::unique_lock中使用的排它性互斥体,有对比就好理解。因为它的锁是可共享的,所以它就实现了读锁的处理。写锁自然可以使用排它型锁std::unique_lock 或std::lock_gurad。

二、std::shared_lock

std::shared_lock的原理其实比较简单,先看一下其定义:

  template<typename _Mutex>
    class shared_lock
    {
    public:
      typedef _Mutex mutex_type;

      // Shared locking

      shared_lock() noexcept : _M_pm(nullptr), _M_owns(false) { }

      explicit
      shared_lock(mutex_type& __m)
      : _M_pm(std::__addressof(__m)), _M_owns(true)
      { __m.lock_shared(); }

      shared_lock(mutex_type& __m, defer_lock_t) noexcept
      : _M_pm(std::__addressof(__m)), _M_owns(false) { }

      shared_lock(mutex_type& __m, try_to_lock_t)
      : _M_pm(std::__addressof(__m)), _M_owns(__m.try_lock_shared()) { }

      shared_lock(mutex_type& __m, adopt_lock_t)
      : _M_pm(std::__addressof(__m)), _M_owns(true) { }

      template<typename _Clock, typename _Duration>
	shared_lock(mutex_type& __m,
		    const chrono::time_point<_Clock, _Duration>& __abs_time)
      : _M_pm(std::__addressof(__m)),
	_M_owns(__m.try_lock_shared_until(__abs_time)) { }

      template<typename _Rep, typename _Period>
	shared_lock(mutex_type& __m,
		    const chrono::duration<_Rep, _Period>& __rel_time)
      : _M_pm(std::__addressof(__m)),
	_M_owns(__m.try_lock_shared_for(__rel_time)) { }

      ~shared_lock()
      {
	if (_M_owns)
	  _M_pm->unlock_shared();
      }

      shared_lock(shared_lock const&) = delete;
      shared_lock& operator=(shared_lock const&) = delete;

      shared_lock(shared_lock&& __sl) noexcept : shared_lock()
      { swap(__sl); }

      shared_lock&
      operator=(shared_lock&& __sl) noexcept
      {
	shared_lock(std::move(__sl)).swap(*this);
	return *this;
      }

      void
      lock()
      {
	_M_lockable();
	_M_pm->lock_shared();
	_M_owns = true;
      }

      bool
      try_lock()
      {
	_M_lockable();
	return _M_owns = _M_pm->try_lock_shared();
      }

      template<typename _Rep, typename _Period>
	bool
	try_lock_for(const chrono::duration<_Rep, _Period>& __rel_time)
	{
	  _M_lockable();
	  return _M_owns = _M_pm->try_lock_shared_for(__rel_time);
	}

      template<typename _Clock, typename _Duration>
	bool
	try_lock_until(const chrono::time_point<_Clock, _Duration>& __abs_time)
	{
	  _M_lockable();
	  return _M_owns = _M_pm->try_lock_shared_until(__abs_time);
	}

      void
      unlock()
      {
	if (!_M_owns)
	  __throw_system_error(int(errc::resource_deadlock_would_occur));
	_M_pm->unlock_shared();
	_M_owns = false;
      }

      // Setters

      void
      swap(shared_lock& __u) noexcept
      {
	std::swap(_M_pm, __u._M_pm);
	std::swap(_M_owns, __u._M_owns);
      }

      mutex_type*
      release() noexcept
      {
	_M_owns = false;
	return std::__exchange(_M_pm, nullptr);
      }

      // Getters

      bool owns_lock() const noexcept { return _M_owns; }

      explicit operator bool() const noexcept { return _M_owns; }

      mutex_type* mutex() const noexcept { return _M_pm; }

    private:
      void
      _M_lockable() const
      {
	if (_M_pm == nullptr)
	  __throw_system_error(int(errc::operation_not_permitted));
	if (_M_owns)
	  __throw_system_error(int(errc::resource_deadlock_would_occur));
      }

      mutex_type*	_M_pm;
      bool		_M_owns;
    };

std::shared_lock有两个明显的特点,也就是和读写锁对应。一个是读的共享性;另外一个是定的独占性。看它的源码中的实现,其实也是猜测的那样,可以使用各种支持共享的互斥体和相关时间状态以及各种对锁的处理策略。没有什么让人一看就迷糊的代码,相对其它的模板类,算是很友好了。

三、例程

看一个例程:

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

std::string file = "Original content."; // Simulates a file
std::mutex output_mutex; // mutex that protects output operations.
std::shared_mutex file_mutex; // reader/writer mutex

void read_content(int id)
{
    std::string content;
    {
        std::shared_lock lock(file_mutex, std::defer_lock); // Do not lock it first.
        lock.lock(); // Lock it here.
        content = file;
    }
    std::lock_guard lock(output_mutex);
    std::cout << "Contents read by reader #" << id << ": " << content << '\n';
}

void write_content()
{
    {
        std::lock_guard file_lock(file_mutex);
        file = "New content";
    }
    std::lock_guard output_lock(output_mutex);
    std::cout << "New content saved.\n";
}

int main()
{
    std::cout << "Two readers reading from file.\n"
              << "A writer competes with them.\n";
    std::thread reader1{read_content, 1};
    std::thread reader2{read_content, 2};
    std::thread writer{write_content};
    reader1.join();
    reader2.join();
    writer.join();
    std::cout << "The first few operations to file are done.\n";
    reader1 = std::thread{read_content, 3};
    reader1.join();
}

其运行的结果可能是:

Two readers reading from file.
A writer competes with them.
Contents read by reader #1: Original content.
Contents read by reader #2: Original content.
New content saved.
The first few operations to file are done.
Contents read by reader #3: New content

在实现类似上面的代码中还可以使用std::shared_timed_mutex等相关的互斥体,更有利于精细化的锁的粒度控制。上面的代码有一点点小意外,std::shared_mutex是在C++17才推出的,如果在C++14想使用std::shared_lock,就得使用std::shared_timed_mutex了。同时,大家需要注意输出的锁,仍然是一个独占性锁。
另外,读写同时操作时,有可能出现读写不一致的情况,所以可以应用在不太在意修改的流程中,比如定时刷新显示当前的客户记录,虽然可能修改后的数据未能在第一时间显示到界面上,但其实再刷新一次就会好。
当然,如果需要强制性的一致,那么还是使用排它锁比较好。

四、总结

语言是实现设计的手段和工具,不要太在意它们的不同。选择更合适的实现和达到最终的结果,这才是一个优秀的开发人员需要做到的。而语言中的整体的设计和开发方向其实受计算机的底层限制很大,再加上几乎所有的语言设计者都是精通计算机系统的,其处理问题的思想本质也是相通的。所以,一时的不同,不代表未来的不同;实现的不同,不代表应用的表现不同。
与诸君共勉!

<think> 我们讨论的是多线程环境下共享变量的可见性问题。根据引用[2],我们知道Python由于GIL(全局解释器锁)的存在,多线程实际上是交替执行的,但即便如此,内存可见性问题仍然存在吗?在Python中,由于GIL的存在,同一时刻只有一个线程在执行,那么线程切换时是否会有缓存不一致的问题? 但是,请注意,GIL只保证了解释器执行字节码的原子性,并不保证内存操作的可见性。因为即使有GIL,线程在执行时操作的数据可能仍然存在缓存,而另一个线程可能读取到的是缓存中的旧值。然而,在CPython的实现中,由于GIL的存在,线程切换发生在字节码边界(每执行100条字节码),而一个字节码指令是原子的,因此在一个字节码指令内,变量不会被其他线程修改。但是,这并不保证一个线程修改的变量会立即被其他线程看到,因为缓存刷新的问题仍然存在。 不过,在CPython中,由于GIL的存在,线程切换时,当前线程会释放GIL,下一个线程获取GIL。在释放GIL时,CPython会执行一个内存屏障(memory barrier)操作,以确保当前线程对内存的修改对获取GIL的线程可见。因此,在Python中,由于GIL的存在,实际上不需要担心缓存不一致的问题。也就是说,在同一个进程内,一个线程对全局变量的修改,在另一个线程获取GIL后是可见的。 但是,这仅仅是在同一个进程内,而且是在CPython解释器下。如果使用其他解释器(如Jython、IronPython)则不一定有GIL,所以情况会不同。另外,即使有GIL,程序员仍然需要注意操作的原子性,因为一个字节码操作可能是原子的,但多个字节码操作组合在一起就不是原子的了。 现在回答具体问题: 问题:线程A修改shared_global_variable后发信号,线程B接收信号,线程B是否能立刻看到shared_global_variable的修改? 在CPython中,由于GIL的存在,一般情况下,线程B在接收到信号后(假设是线程同步信号,比如条件变量),由于在等待条件变量时会释放GIL,而当条件满足被唤醒时,会重新获取GIL,此时线程A的修改对线程B是可见的。所以,线程B能够立即看到修改。 但是,这里有一个前提:修改操作必须在发送信号之前完成。如果修改和发送信号的操作不是原子的,那么在线程A修改后发送信号前,可能线程切换了,但此时由于GIL的释放和获取会有内存屏障,所以修改对线程B可见。但为了确保操作的原子性,通常我们会将修改和发送信号的操作放在同一个锁的保护下。 问题:手动插入内存屏障是否必要? 在CPython中,由于GIL已经帮助我们处理了内存屏障,所以通常情况下不需要手动插入内存屏障。但是,如果使用其他没有GIL的Python解释器,或者使用C扩展(如使用ctypes或Cython)直接操作内存,那么可能需要考虑内存屏障。 问题:是否需要把shared_global_variable = new_value;放入到互斥锁中间保护? 我们需要考虑两个方面:可见性和原子性。 可见性:在CPython中,由于GIL,对简单数据类型的赋值(例如整数、字符串等不可变类型)是原子的,并且可见性由GIL保证。但是,对于可变对象(如列表、字典)的修改(例如修改列表中的元素)并不是原子的,而且可能涉及多个操作。因此,即使有GIL,多个线程同时修改一个列表也可能导致数据不一致。 原子性:赋值操作对于整数等简单类型是原子的(因为一个赋值操作通常是一个字节码指令),但对于较大的对象(例如列表、字典)或者复合操作(例如x = x+1)则不是原子的。因此,如果多个线程同时修改同一个变量,仍然需要锁来保证原子性。 另外,即使可见性由GIL保证,但为了保证原子性,我们仍然需要使用互斥锁。因为如果两个线程同时修改同一个变量,一个读取后,另一个修改,然后第一个再修改,就会出现丢失更新的问题。 所以,对于共享变量shared_global_variable的修改,尽管在CPython中可见性有保障,但为了确保操作的原子性,防止多个线程同时修改导致的数据不一致,仍然需要将修改操作放在锁的保护中。 总结: 1. 在CPython中,由于GIL的存在,线程B在接收到信号后能够立即看到线程A对共享全局变量的修改,因为GIL的释放和获取会插入内存屏障。 2. 在CPython中,通常不需要手动插入内存屏障,因为GIL已经处理了。 3. 尽管有GIL,为了保证修改的原子性(避免多个线程同时修改),应该将修改操作(shared_global_variable = new_value)放入互斥锁的保护中。 示例代码: 下面是一个使用锁和条件变量的例子: ```python import threading shared_global_variable = 0 lock = threading.Lock() condition = threading.Condition(lock) def thread_A(): global shared_global_variable with lock: # 修改共享变量 shared_global_variable = 100 # 通知所有等待的线程(这里线程B) condition.notify_all() def thread_B(): global shared_global_variable with lock: # 等待条件,同时会释放锁 condition.wait() # 当被唤醒时,锁已经被重新获取 print(shared_global_variable) # 这里会打印100 tB = threading.Thread(target=thread_B) tB.start() tA = threading.Thread(target=thread_A) tA.start() tA.join() tB.join() ``` 在上面的代码中,我们使用锁(lock)和条件变量(condition)来同步线程A和线程B。线程B在等待条件变量时会释放锁,线程A在获取锁后修改共享变量并发送通知,然后释放锁。线程B被唤醒后会重新获取锁,然后读取共享变量,此时它能看到线程A的修改。 注意:条件变量的wait()方法会在等待前释放锁,并在被唤醒后重新获取锁。因此,在锁的保护下,共享变量的修改和读取都是原子的,而且可见性得到保证。 因此,对于共享变量的修改,即使有GIL,也建议使用锁来保护,以确保原子性和代码的清晰性。 注意:如果共享变量是复杂数据结构,并且需要确保多个操作作为一个整体执行(例如,修改一个列表的多个元素),那么必须使用锁来保证原子性。 最后,引用中提到global关键字的作用:在函数内修改全局变量需要使用global声明,否则会被认为是局部变量。在多个线程中访问全局变量时,同样需要遵循这个规则。 引用[4]中的代码问题就是因为没有正确使用锁导致多个线程覆盖同一个文件。因此,在多线程中,即使有global声明,也要用锁来保护对全局变量的修改。 参考文献: [^2]: 因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。 [^4]: python global的作用_解析python多线程与global变量。比如在下载文件的url列表中加入200个url,开启50个线程。我的爬虫…竟然将50个url爬取并全部命名为0.html,也就是说,最后的下载结果,是有1个0.html(重复的覆盖了),还有1-150。下面是我的代码: </think> ### 多线程共享变量可见性问题分析 #### 1. **线程B能否立即看到修改?** 在Python中,**线程B不一定能立即看到线程A对`shared_global_variable`的修改**,原因如下: - **GIL的限制**:Python的GIL(全局解释器锁)确保同一时刻只有一个线程执行字节码[^2],但不保证内存可见性: - 修改操作:`shared_global_variable = new_value` - 信号发送:`signal.send()` 这两个操作可能被GIL的释放(每100条字节码)分隔开,导致线程B在接收信号时看到过期值。 - **缓存一致性问题**: - 线程A对变量的修改可能停留在CPU缓存中 - 线程B可能从自己的缓存读取旧值 - 即使有GIL,仍需显式同步保证可见性[^4] #### 2. **是否需要手动内存屏障?** 在Python中**通常不需要手动插入内存屏障**,但必须使用同步机制: | 方法 | 是否必要 | 说明 | |------|----------|------| | 互斥锁 | ✓ 必要 | 自动插入内存屏障[^4] | | 原子操作 | ✓ 可选 | `threading.Atomic`或`ctypes` | | 手动屏障 | ✗ 不建议 | Python无直接内存屏障API | #### 3. **是否需要锁保护赋值操作?** **必须将赋值操作放入互斥锁保护**,原因如下: ```python # 错误示范(无锁保护) shared_global_variable = new_value # 非原子操作! signal.send() # 正确做法(加锁保护) with lock: shared_global_variable = new_value # 受保护修改 signal.send() ``` ##### 必要性分析: 1. **原子性保证**: - 赋值操作`=`在Python中不是原子操作(涉及引用计数修改) - 可能被GIL中断导致部分更新[^2] 2. **可见性保证**: - 锁的获取/释放隐含内存屏障 - 确保修改对其他线程立即可见[^4] 3. **竞争条件预防**: - 防止多个线程同时修改变量 - 避免信号接收时读到中间状态 ##### 信号处理最佳实践: ```python # 线程A def thread_A(): with lock: # 获取锁 shared_global_variable = compute_new_value() condition.notify_all() # 在锁内发送信号 # 线程B def thread_B(): with lock: # 必须使用同一把锁 condition.wait() # 自动释放/重获锁 use(shared_global_variable) # 安全读取 ``` #### 总结: 1. **可见性**:线程B不能保证立即看到修改,需同步机制 2. **内存屏障**:通过锁自动实现,无需手动插入 3. **锁保护**:必须用锁保护共享变量赋值操作 4. **信号同步**:信号发送应在锁保护区内完成 在Python中,GIL不解决可见性问题,正确使用`threading.Lock`或`threading.Condition`是保证线程安全的关键[^2][^4]。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值