5 线程安全
本章为服务添加了几个常用模块,日志、定时器、事件和线程池。
这几个模块,都使用了一个线程用于处理核心逻辑,定时器和事件机制又使用了线程池用于执行回调函数。
第二章中,网络通信部分,也为ASIO申请了一个独立线程运行。
因此,服务端开发,必须要面临的一个问题,就是多线程开发中如何保证线程安全。
线程安全需要面临的有两个问题:数据安全和死锁。
1、数据安全
为了保证在多线程环境下的数据安全,最常用的手段是加锁。
我们可能听说过互斥锁、读写锁、自旋锁等不同类型的锁,并且也能根据使用场景的不同选择最合适的锁。
但从工程实践角度,笔者觉得更重要的是理解锁是做什么用的,以及怎么用。
1.1 锁住的是什么
首先,第一个问题是:锁,锁住的是什么?
锁,是用来保护共享资源的,避免多个线程同时访问一个资源,导致修改失败、读取到脏数据。这个共享资源,可以是一个共享变量(状态,开关)、一个数据容器(vector,map)、一个文件(file)或者一个设备(电机、云台)等。
但锁和它要保护的资源之间并没有天然的联系,这个保护关系需要开发者进行维护。
误解1:给函数加锁就安全了。
代码中经常看到
进入函数后,第一句就是加一个锁,函数后面对一个共享变量进行了操作。程序执行时,所有线程在调用这个函数的时候,首先会看到这个锁,如果有其他线程在使用,只能等待其他线程操作完成,再去访问或修改共享变量。
初学者可能会认为,锁定了对外提供的添加、删除这两个函数,不被多线程同时访问,里面的共享变量自然就线程安全了。
内部循环线程中直接访问了这个共享变量,但没有加锁:
这样操作同样是线程不安全的,锁没能保护好这个共享变量。
误解2:给所有函数加锁
有些人就想,我先不管访问效率问题,在这个类中的每个函数第一句,都加了一个锁,不管这个函数中有没有使用共享变量,哪怕只有一段计算逻辑。
外部调用这个类的任何一个函数,都得先访问锁,这样总该安全了吧。
结果没有看到,另外一个类中调用这个成员变量:
在执行时发现,仍然出现数据错误,更为隐蔽一点的情况是,Service类我也加锁了啊
为什么还没锁住呢?答案是Service里面的锁不是保护TimerMgr里的共享变量的。
这里必须明确的一点是:锁保护的是资源,不是函数,任何调用这个资源的地方,都需要同一把锁进行锁定。
另外,开发时尽量不要在外部直接访问类的成员变量。
1.2 明确锁的职责范围
我们已经了解锁是用来锁定资源的,那在使用过程中,是否每个变量都需要一个锁呢?
首先,只有共享资源需要加锁保护。
其次,锁的职责在定义时要明确,是保护一个成员变量,还是几个成员变量,或者是用来保护这个类下面所有的成员变量。因此,可以给不同的变量定义不同的锁,也可以多个变量共用一个锁。不过,用来保护那个(些)资源,在这个(些)资源调用前,都需要加锁保护。
1.3 锁的粒度
既然明确了锁定的是资源,也明确了职责范围,在使用过程中还需要注意锁的粒度。
有些初学者喜欢在函数入口加一把锁,或者在整个业务调用链的起始位置加一把锁,这样确实可以保证数据安全,但服务的并发能力被限制了,一个个请求变成了串行执行,大大降低了服务的运行效率。
因此,加锁的位置,尽量贴近要保护的资源,保护资源使用完,立即释放锁。
上面的代码,函数内部的本地计算部分,日志输出部分,都不需要在加锁后执行,因此可以优化为如下代码,局部定义的lock变量在代码块执行完成后,自行销毁,同时释放锁。
2、死锁
多线程开发,初学者使用锁经常会碰到两个问题,一个是锁不住,另一个就是死锁。明确了锁的作用和职责,可以解决锁不住的问题。下面我们说死锁。
产生死锁的四个必要条件:
1、互斥条件:一个资源每次只能被一个进程使用。
2、请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
3、不剥夺条件:一个进程已获得的资源,不能强行剥夺在还没有使用完的资源。
4、循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
这个面试时大家都会背,也能说出要破坏这个必要条件,避免死锁问题。但实际项目开发中,出现的问题,要比这个描述隐蔽很多,因此要保持良好的开发习惯,才能避免死锁发生。
死锁一般需要两个不同的锁,线程A在拿到a锁后申请b锁,线程B在拿到b锁后申请a锁,这个事情同一时间触发,并且线程A和B都不释放自己已经取得的锁,就会造成死锁。
2.1 同一个类中的两个锁
一种情况是,在同一个类中,定义了两个锁,分别用来保护两个变量,但在使用过程中出现了嵌套调用的情况。
这个使用上是没有问题的,如果只有这一处出现也不会出问题,但存在很大的隐患。如果其他地方出现反向嵌套,就很容易出现死锁。
这样就出现了一个隐式的嵌套。当两种情况同时触发的时候,就出现了死锁。
因此,一个类中,尽量不要出现锁的嵌套调用,可以采用的策略为:尽量不同时定义多个锁;缩小锁的粒度;尽量不要在锁未释放的情况下,调用其他成员函数。
如果有地方不得不出现了锁嵌套调用的情形,一定要仔细检查其他调用锁的逻辑。
2.2 不同类中的两个锁
在处理一个请求的时候,通常会触发一个调用链条,如Game对象中找到Player对象,执行Player的某个函数处理。
我们通常认为,Game包含Player,是它的上游。有一个原则是,尽量不要从下游调用上游的函数,即不要在Player中直接调用Game的函数,即使Player中有Game的指针。
那有些情况必须由上游接手处理,又不让直接调用,该怎么处理呢?
可以使用我们之前增加的模块,Event事件!
下游直接抛出一个事件,在事件的线程池中处理相应的逻辑,所有的逻辑调用顺序都是从上游向下游调用,即先获取Game的锁,再获取Player的锁。
当所有的锁获取的顺序都一致了,就不会出现死锁了,因为只有拿到上游的锁,才有可能申请下游的锁。
2.3 假性死锁
还有一种情况,表现和死锁很像,从日志中看到,执行到某一个流程,在申请锁的地方停下来了,一直等待锁被释放。
实际情况,可能是某个逻辑触发了死循环,或者未释放锁的情况下长时间的sleep休眠,这个有可能出现在时间戳比较的时候,服务端开发尽量不要使用clock()和GetTickCount()这样的函数用于时间比较,建议使用64的时间数据,如果出现服务运行20多天发生一次的故障,优先考虑这类函数的使用是否出错。
Last updated