C++服务开发入门指南
  • 序言
  • 前言
  • 一、一个简单的服务
    • 1 什么是服务
    • 2 服务可以用来做什么
    • 3 简单服务框架
  • 二、网络通信服务框架
    • 1 网络服务的基本概念
    • 2 增加监听端口
    • 3 处理客户端会话
    • 小结
  • 三、添加基础模块
    • 1 日志模块
    • 2 定时器
    • 3 事件机制
    • 4 线程池
    • 5 线程安全
    • 小结
  • 四、一个聊天服务
    • 1 需求描述及分析
    • 2 概要设计
    • 3 创建服务项目
    • 4 ClientUser实现
    • 5 RoomMgr实现
    • 6 ChatRoom实现
    • 7 RoomIDMgr实现
    • 小结
  • 五、测试、迭代及重构
    • 1 测试
    • 2 迭代
    • 3 重构
    • 4 版本号
  • 六、架构设计
    • 1 单点服务
    • 2 分布式服务
  • 七、部署及发布
    • 1 部署环境
    • 2 编译环境
    • 3 部署服务
    • 4 发布服务
  • 八、线上问题处理
    • 1 线上问题
    • 2 问题处理
  • 九、程序员的职业规划
    • 职业规划
Powered by GitBook
On this page
  • 1、数据安全
  • 2、死锁
  • 2.2 不同类中的两个锁
  1. 三、添加基础模块

5 线程安全

本章为服务添加了几个常用模块,日志、定时器、事件和线程池。

这几个模块,都使用了一个线程用于处理核心逻辑,定时器和事件机制又使用了线程池用于执行回调函数。

第二章中,网络通信部分,也为ASIO申请了一个独立线程运行。

因此,服务端开发,必须要面临的一个问题,就是多线程开发中如何保证线程安全。

线程安全需要面临的有两个问题:数据安全和死锁。

1、数据安全

为了保证在多线程环境下的数据安全,最常用的手段是加锁。

我们可能听说过互斥锁、读写锁、自旋锁等不同类型的锁,并且也能根据使用场景的不同选择最合适的锁。

但从工程实践角度,笔者觉得更重要的是理解锁是做什么用的,以及怎么用。

1.1 锁住的是什么

首先,第一个问题是:锁,锁住的是什么?

锁,是用来保护共享资源的,避免多个线程同时访问一个资源,导致修改失败、读取到脏数据。这个共享资源,可以是一个共享变量(状态,开关)、一个数据容器(vector,map)、一个文件(file)或者一个设备(电机、云台)等。

但锁和它要保护的资源之间并没有天然的联系,这个保护关系需要开发者进行维护。

误解1:给函数加锁就安全了。

代码中经常看到

CHYTimer* TimerMgr::SetTimer(CHYTimer& timer, TimerCBFunc func)
{
    AutoLock lock(m_lockListTimer);
    // do something to m_listTimer
    // do something other
}

bool TimerMgr::DelTimer(CHYTimer* timer)
{
    AutoLock lock(m_lockListTimer);
    // do something to m_listTimer
}

进入函数后,第一句就是加一个锁,函数后面对一个共享变量进行了操作。程序执行时,所有线程在调用这个函数的时候,首先会看到这个锁,如果有其他线程在使用,只能等待其他线程操作完成,再去访问或修改共享变量。

初学者可能会认为,锁定了对外提供的添加、删除这两个函数,不被多线程同时访问,里面的共享变量自然就线程安全了。

内部循环线程中直接访问了这个共享变量,但没有加锁:

unsigned long TimerMgr::OnTimer(CHYTimer* timer)
{
    // do something to m_listTimer
}

这样操作同样是线程不安全的,锁没能保护好这个共享变量。

误解2:给所有函数加锁

有些人就想,我先不管访问效率问题,在这个类中的每个函数第一句,都加了一个锁,不管这个函数中有没有使用共享变量,哪怕只有一段计算逻辑。

外部调用这个类的任何一个函数,都得先访问锁,这样总该安全了吧。

结果没有看到,另外一个类中调用这个成员变量:

unsigned long Service::OnTimer(CHYTimer* timer)
{
    // do something to TimerMgr::m_listTimer
}

在执行时发现,仍然出现数据错误,更为隐蔽一点的情况是,Service类我也加锁了啊

unsigned long Service::OnTimer(CHYTimer* timer)
{
     AutoLock lock(m_lockService);
    // do something to TimerMgr::m_listTimer
}

为什么还没锁住呢?答案是Service里面的锁不是保护TimerMgr里的共享变量的。

这里必须明确的一点是:锁保护的是资源,不是函数,任何调用这个资源的地方,都需要同一把锁进行锁定。

另外,开发时尽量不要在外部直接访问类的成员变量。

1.2 明确锁的职责范围

我们已经了解锁是用来锁定资源的,那在使用过程中,是否每个变量都需要一个锁呢?

首先,只有共享资源需要加锁保护。

其次,锁的职责在定义时要明确,是保护一个成员变量,还是几个成员变量,或者是用来保护这个类下面所有的成员变量。因此,可以给不同的变量定义不同的锁,也可以多个变量共用一个锁。不过,用来保护那个(些)资源,在这个(些)资源调用前,都需要加锁保护。

1.3 锁的粒度

既然明确了锁定的是资源,也明确了职责范围,在使用过程中还需要注意锁的粒度。

有些初学者喜欢在函数入口加一把锁,或者在整个业务调用链的起始位置加一把锁,这样确实可以保证数据安全,但服务的并发能力被限制了,一个个请求变成了串行执行,大大降低了服务的运行效率。

因此,加锁的位置,尽量贴近要保护的资源,保护资源使用完,立即释放锁。

CHYTimer* TimerMgr::SetTimer(CHYTimer& timer, TimerCBFunc func)
{
    AutoLock lock(m_lockListTimer);
    // get local time
    // print some log
    // do something to m_listTimer
    // do something other
}

上面的代码,函数内部的本地计算部分,日志输出部分,都不需要在加锁后执行,因此可以优化为如下代码,局部定义的lock变量在代码块执行完成后,自行销毁,同时释放锁。

CHYTimer* TimerMgr::SetTimer(CHYTimer& timer, TimerCBFunc func)
{
    // get local time
    // print some log
    {
        AutoLock lock(m_lockListTimer);
        // do something to m_listTimer
    }
    // do something other
}

2、死锁

多线程开发,初学者使用锁经常会碰到两个问题,一个是锁不住,另一个就是死锁。明确了锁的作用和职责,可以解决锁不住的问题。下面我们说死锁。

产生死锁的四个必要条件:

1、互斥条件:一个资源每次只能被一个进程使用。

2、请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

3、不剥夺条件:一个进程已获得的资源,不能强行剥夺在还没有使用完的资源。

4、循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

这个面试时大家都会背,也能说出要破坏这个必要条件,避免死锁问题。但实际项目开发中,出现的问题,要比这个描述隐蔽很多,因此要保持良好的开发习惯,才能避免死锁发生。

死锁一般需要两个不同的锁,线程A在拿到a锁后申请b锁,线程B在拿到b锁后申请a锁,这个事情同一时间触发,并且线程A和B都不释放自己已经取得的锁,就会造成死锁。

2.1 同一个类中的两个锁

一种情况是,在同一个类中,定义了两个锁,分别用来保护两个变量,但在使用过程中出现了嵌套调用的情况。

void classXX::funcA()
{
    lock_a.lock();
    lock_b.lock();
    ...
    lock_b.unlock();
    lock_a.unlock();
}

这个使用上是没有问题的,如果只有这一处出现也不会出问题,但存在很大的隐患。如果其他地方出现反向嵌套,就很容易出现死锁。

void classXX::funcB()
{
    lock_b.lock();
    ...
    funcAA();
    ...
    lock_b.unlock();
}

void classXX::funcAA()
{
    lock_a.lock();
    ...
    lock_a.unlock();
}

这样就出现了一个隐式的嵌套。当两种情况同时触发的时候,就出现了死锁。

因此,一个类中,尽量不要出现锁的嵌套调用,可以采用的策略为:尽量不同时定义多个锁;缩小锁的粒度;尽量不要在锁未释放的情况下,调用其他成员函数。

如果有地方不得不出现了锁嵌套调用的情形,一定要仔细检查其他调用锁的逻辑。

2.2 不同类中的两个锁

在处理一个请求的时候,通常会触发一个调用链条,如Game对象中找到Player对象,执行Player的某个函数处理。

// class Game 
void Game::OnPlayerReq(int pid)
{
    AutoLock lock(m_lockGame);
    Player* player = findPlayer(pid);
    player->DoReq();
}

void Game::NotifyOtherPlayer()
{
    AutoLock lock(m_lockGame);
    ....
}

// class Player
void Player::DoReq()
{
	AutoLock lock(m_lockPlayer);
	...
}

void Player::OnTimer()
{
    AutoLock lock(m_lockPlayer);
    Game* pGame = m_pGame;
    pGame->NotifyOtherPlayer();
}

我们通常认为,Game包含Player,是它的上游。有一个原则是,尽量不要从下游调用上游的函数,即不要在Player中直接调用Game的函数,即使Player中有Game的指针。

那有些情况必须由上游接手处理,又不让直接调用,该怎么处理呢?

可以使用我们之前增加的模块,Event事件!

下游直接抛出一个事件,在事件的线程池中处理相应的逻辑,所有的逻辑调用顺序都是从上游向下游调用,即先获取Game的锁,再获取Player的锁。

当所有的锁获取的顺序都一致了,就不会出现死锁了,因为只有拿到上游的锁,才有可能申请下游的锁。

// class Game 
void Game::OnPlayerReq(int pid)
{
    AutoLock lock(m_lockGame);
    Player* player = findPlayer(pid);
    player->DoReq();
}

void Game::NotifyOtherPlayer()
{
    AutoLock lock(m_lockGame);
    ....
}

void Game::OnEvent_NotifyOtherPlayer()
{
    NotifyOtherPlayer();
}

// class Player
void Player::DoReq()
{
	AutoLock lock(m_lockPlayer);
	...
}

void Player::OnTimer()
{
    AutoLock lock(m_lockPlayer);
    PostEvent_NotifyOtherPlayer();
}

2.3 假性死锁

还有一种情况,表现和死锁很像,从日志中看到,执行到某一个流程,在申请锁的地方停下来了,一直等待锁被释放。

实际情况,可能是某个逻辑触发了死循环,或者未释放锁的情况下长时间的sleep休眠,这个有可能出现在时间戳比较的时候,服务端开发尽量不要使用clock()和GetTickCount()这样的函数用于时间比较,建议使用64的时间数据,如果出现服务运行20多天发生一次的故障,优先考虑这类函数的使用是否出错。

Previous4 线程池Next小结

Last updated 2 years ago