CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • 第1章高频C++11重难点知识解析

  • 第2章Linux GDB高级调试指南

  • 第3章C++多线程编程从入门到进阶

    • 3.1 线程的基本概念
    • 3.2 线程基本操作
    • 3.3 线程函数传C++类实例指针惯用法
    • 3.4 整型变量的原子操作
    • 3.5 Linux线程同步对象
    • 3.6 Windows 线程资源同步对象
    • 3.7 C++ 11/14/17 线程同步对象
    • 3.8 如何确保创建的线程一定运行起来?
    • 3.9 多线程使用锁实践经验总结
      • 3.10 线程局部存储
      • 3.11 C 库的非线程安全函数
      • 3.12 线程池与队列系统的设计
      • 3.13 纤程(Fiber)与协程(Coroutine)
      • 3.14 本章总结
    • 第4章C++网络编程重难点解析

    • 第5章网络通信故障排查常用命令

    • 第6章高性能网络通信协议设计精要

    • 第7章高性能服务结构设计

    • 第8章Redis 网络通信模块源码分析

    • 第9章后端服务重要模块设计探索

    • C++后端开发进阶
    • 第3章C++多线程编程从入门到进阶
    zhangxf
    2023-04-05
    目录

    3.9 多线程使用锁实践经验总结

    前面的章节,我们介绍了 Windows 和 Linux 操作系统提供的各种常用锁对象的使用原理使用方法。多线程编程少不了与这些锁打交道,在使用锁时稍不注意就可能会造成死锁或者程序性能的问题。

    关于锁的使用,根据我的经验总结如下几点:

    # 3.9.1 减少锁的使用

    实际开发中能不使用锁尽量不使用锁,当然这不是绝对的,如果使用锁也能满足性能要求,使用锁也无妨,一般使用了锁的代码会带来如下性能损失:

    • 加锁和解锁操作,本身有一定的开销;
    • 临界区的代码不能并发执行;
    • 进入临界区的次数过于频繁,线程之间对临界区的争夺太过激烈,若线程竞争互斥体失败,就会陷入阻塞,让出 CPU,所以执行上下文切换的次数要远远多于不使用互斥体的版本。

    替代锁的方式有很多,如无锁队列。

    # 3.9.2 明确锁的范围

    看下面这段代码:

    if(hashtable.is_empty())
    {
        pthread_mutex_lock(&mutex);
        htable_insert(hashtable, &elem);
        pthread_mutex_unlock(&mutex);
    }
    
    1
    2
    3
    4
    5
    6

    读者能看出这段代码的问题吗?代码行 4 虽然对 hashtable 的插入使用了锁做保护,但是判断 hash_table 是否为空也需要使用锁保护,所以正确的写法应该是:

    pthread_mutex_lock(&mutex);
    if(hashtable.is_empty())
    {   
        htable_insert(hashtable, &elem);  
    }
    pthread_mutex_unlock(&mutex);
    
    1
    2
    3
    4
    5
    6

    # 3.9.3 减少锁的粒度

    所谓减小锁使用粒度指的是尽量减小锁作用的临界区代码范围,临界区的代码范围越小,多个线程排队进入临界区的时间就会越短。这就类似高速公路上堵车,如果堵车的路段越长,那么后续过来的车辆通行等待时间就会越长。

    我们来看两个具体的例子:

    示例一

    void TaskPool::addTask(Task* task)
    {
        std::lock_guard<std::mutex> guard(m_mutexList); 
        std::shared_ptr<Task> spTask;
        spTask.reset(task);            
        m_taskList.push_back(spTask);
              
        m_cv.notify_one();
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

    上述代码中 guard 锁保护 m_taskList,仔细分析下这段代码发现,代码行 4、5 和 8 行其实没必要作为临界区内的代码的,所以建议挪到临界区外面去,修改如下:

    void TaskPool::addTask(Task* task)
    {
        std::shared_ptr<Task> spTask;
        spTask.reset(task);
    
        {
            std::lock_guard<std::mutex> guard(m_mutexList);             
            m_taskList.push_back(spTask);
        }
        
        m_cv.notify_one();
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    修改之后, guard 锁的作用范围就是 7 、8 两行了,仅对 **m_taskList.push_back() **操作做保护,这样锁的粒度就变小了。

    示例二

    void EventLoop::doPendingFunctors()
    {
        std::unique_lock<std::mutex> lock(m_mutex);
    	for (size_t i = 0; i < m_pendingFunctors.size(); ++i)
    	{
    		m_pendingFunctors[i]();
    	}
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

    上述代码中 m_pendingFunctors 是被锁保护的对象,它的类型是 std::vector<Functor>,这样的代码效率比较低,必须等当前线程挨个处理完 m_pendingFunctors 中的元素后其他线程才能操作 m_pendingFunctors 。修改代码如下:

    void EventLoop::doPendingFunctors()
    {
    	std::vector<Functor> functors;
    	
    	{
    		std::unique_lock<std::mutex> lock(m_mutex);
    		functors.swap(m_pendingFunctors);
    	}
    
    	for (size_t i = 0; i < functors.size(); ++i)
    	{
    		functors[i]();
    	}	
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    修改之后的代码使用了一个局部变量 functors,然后把 m_pendingFunctors 中的内容倒换到 functors 中,这样就可以释放锁了,允许其他线程操作 m_pendingFunctors ,现在只要继续操作本地对象 functors 就可以了,提高了效率。

    # 3.9.4 避免死锁的一些建议

    • **一个函数中,如果有一个加锁操作,那么一定要记得在函数退出时记得解锁,且每个退出路径上都不要忘记解锁路径。**例如:

      void some_func()
      {
      	//加锁代码
      	
      	if (条件1)
      	{
      		//其他代码
      		//解锁代码
      		return;
      	} 
      	else
      	{
      		//其他代码
      		//解锁代码
      		return;
      	}
      	
      	
      	if (条件2)
      	{
      		if (条件3)
      		{
      			//其他代码
      			//解锁代码
      			return;
      		}
      		
      		if (条件4)
      		{
      			//其他代码
      			//解锁代码
      			return;
      		}	
      	} 
      	
      	while (条件5)
      	{
      		if (条件6)
      		{
      			//其他代码
      			//解锁代码
      			return;
      		}
      	}
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45

      上述函数中每个逻辑出口处都需要写上解锁代码。前面也说过,这种逻辑非常容易因为疏忽忘记在某个地方加上解锁代码而造成死锁,所以一般建议使用 RAII 技术将加锁和解锁代码封装起来,也就是说,我们可以定义一个锁对象包装类,其成员变量含有一个锁对象,在该包装类的构造函数中对锁对象进行加锁,在析构函数中对锁对象进行解锁,这样在函数出口处,锁对象包装类由于出了作用域被析构进而对其锁对象进行解锁。按照这个方法则上述代码就不需要在每个出口处加上解锁代码了:

      //锁
      class LockWrapper final
      {
      public:
      	LockWrapper(SomeLock& lock) : someLock(lock)
      	{
      		//对someLock进行加锁
      	}
      	
      	~LockWrapper()
      	{
      		//对someLock进行解锁
      	}
      
      private:
      	//某个锁对象,如std::mutex、pthread_mutex_t等类型
      	SomeLock&   someLock;
      };
      
      void some_func()
      {
      	SomeLock someLock;
      	//在LockWrapper中对someLock进行加锁
      	LockWrapper wrapper(someLock);
      	
      	if (条件1)
      	{
      		//其他代码
      		return;
      	} 
      	else
      	{
      		//其他代码
      		return;
      	}
      	
      	
      	if (条件2)
      	{
      		if (条件3)
      		{
      			//其他代码
      			return;
      		}
      		
      		if (条件4)
      		{
      			//其他代码
      			return;
      		}	
      	} 
      	
      	while (条件5)
      	{
      		if (条件6)
      		{
      			//其他代码
      			return;
      		}
      	}
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61

      前面章节也介绍了这种利用 RAII 技术实现自动加解锁的方法,如boost::scoped_lock 和 std::lock_guard 的实现。

    • 线程退出时一定要及时释放其持有的锁

      实际开发中会因一些特殊需求创建一些临时线程,这些线程执行完相应的任务后就会退出。对于这类线程,如果其持有了锁,在线程退出时,一定要记得释放其持有的锁对象。

    • 多线程请求锁的方向要一致,以避免死锁

      假设现在有两个锁 A 和 B,线程 1 在请求了锁 A 之后再请求 B,线程 2 在请求了锁 B 后再请求锁 A,这种线程请求锁的方向就不一致了,线程 1 的方向是从 A 到 B,线程 2 的方向是从 B 到 A,多个线程请求锁的方向不一致容易造成死锁。所以建议的方式是 线程 1 和 线程 2 请求锁的方向保持一致,要么都从 A 到 B,要么都从 B 到 A。

    • 当需要同一个线程重复请求一个锁时,搞清楚你所使用的锁的行为,是递增锁引用计数,还是会阻塞抑或是直接获得锁?

    # 3.9.5 避免活锁的一些建议

    前面说了避免“死锁”,读者应该能理解,但是这里突然出现了避免“活锁”,我相信很多人看到这个标题一下子就懵了。所谓活锁就是,当多个线程使用 trylock 系列的函数时,由于多个线程相互谦让,导致即使在某段时间内锁资源是可用的,也可能导致需要锁的线程拿不到锁。举个生活中的例子,马路上两个人迎面走来,两个人同时往一个方向避让,原来本意是给对方让路,结果还是发生了碰撞。

    我们在实际编码时,尽量避免让过多的线程使用 trylock 请求锁,以免出现“活锁”现象,这是对资源的一种浪费。

    上次更新: 2025/04/01, 20:53:14
    3.8 如何确保创建的线程一定运行起来?
    3.10 线程局部存储

    ← 3.8 如何确保创建的线程一定运行起来? 3.10 线程局部存储→

    最近更新
    01
    第二章 关键字static及其不同用法
    03-27
    02
    第一章 auto与类型推导
    03-27
    03
    C++语言面试问题集锦 目录与说明
    03-27
    更多文章>
    Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式