当多个线程或者进程同时访问公共资源的时候就需要同步。比如说一个线程准备访问堆上的一块内存,另外的一个线程把他给释放了但是还没有来得及设置为空,那么就会出现问题,这个时候就应该线程同步,比如让第一个线程判空访问完了,第二个线程在释放置空;或者让第二个线程释放置空完了,第一个线程在进行判空访问。
简单的说线程同步就是将原本在同一时间访问同一资源的线程,让他们有时间顺序的访问资源,即确保公共资源不被同时访问。但是并不是所有的公共资源都需要同步,比如两个线程都是去只读不写某块内存,或者是具有原子性的公共资源等;加速线程则通过设计算法上避开了同时访问资源,比如多线程处理一个数组,可以第一个线程处理数组的一块,第二个线程处理数组的另一块,从而避免公共内存资源访问。
有多线程处理了公共资源的时候才需要线程同步。这里公共资源不仅仅指堆内存,文件,句柄,端口,管道,共享内存等只要能够被多个线程同时操作的都是公共资源。
线程同步的方法
在windows下,一般会有以下几种:
- 事件(Event);
- 信号量(semaphore);
- 互斥量(mutex);
- 临界区(Critical section)。
其实上述1,2,3同步的方法不仅仅是为了解决同步线程问题,更多的是为了进程间通信服务,有点像生产者消费者的意思,他是windows 内核对象,是平台相关的;临界区则是不跨进程的,所以他效率要比1,2,3的要高。(哪里有功能又多效率又高的东西了~~)。C++中的mutex底层在windows上实现就是使用的临界区。本篇主要讨论C++中的同步方法。
C++ std库提供了三类同步方法,一类是mutex,另外一个是条件变量condition_variable,还有一类是原子性
1、std::mutex的使用
不做同步的代码:
/** 不带参数的线程回调
*/
void ThreadCallBack()
{
for (int i = 0; i < 100; ++i)
{
ColorPrintf(Red, "ThreadCallBack\r\n");
}
}
int main()
{
std::thread td(ThreadCallBack);
for (int i = 0; i < 100; ++i)
{
ColorPrintf(Green, "main\r\n");
}
td.join();
getchar();
return 1;
}
结果如下:
我们发现输出的“main”和“ThreadCallBack”的颜色有红有绿,明显是线程没有同步发生了异常。我们看一下ColorPrintf的部分代码:
#define ColorPrintf(foreColor, fmt, ...)\
do {\
HANDLE handle = ::GetStdHandle(STD_OUTPUT_HANDLE);\
CONSOLE_SCREEN_BUFFER_INFO info;\
if (handle != NULL && ::GetConsoleScreenBufferInfo(handle, &info))\
{\
::SetConsoleTextAttribute(handle, foreColor | Black);\
::printf(fmt, __VA_ARGS__);\
::SetConsoleTextAttribute(handle, info.wAttributes);\
}\
}while (0)
做了以下三步:
- 设置前景颜色
- 输出
- 将前景颜色设置回去
当子线程运行完1设置前景颜色为红色准备输出,这个时候主线程获得运行权运行了1设置了前景颜色为绿色,然后子线程抢到运行权进行输出,那么颜色就不对了,这里的控制台输出就是公共资源需要同步加锁。
同步后的代码:
#include <thread>
#include <mutex>
/** 互斥量
*/
static std::mutex s_mutex;
/** 不带参数的线程回调
*/
void ThreadCallBack()
{
for (int i = 0; i < 100; ++i)
{
s_mutex.lock();
ColorPrintf(Red, "ThreadCallBack\r\n");
s_mutex.unlock();
}
}
int main()
{
std::thread td(ThreadCallBack);
for (int i = 0; i < 100; ++i)
{
s_mutex.lock();
ColorPrintf(Green, "main\r\n");
s_mutex.unlock();
}
td.join();
getchar();
return 1;
}
结果如下:
(上述代码输出) (unlock后面加上::Sleep(1)的输出)
发现结果的颜色虽然对了,但是顺序却是先全部输出main然后输出ThreadCallBack,仔细看一下代码,发现加锁的地方也没有在循环外面啊,这是什么情况?其实将循环改为10000次的话就会出现一段绿一段红,或者在unlock后面加上::Sleep(0)。原因猜测和windows IO机制和多核cpu有关,不在本文讨论范围内。
std::recursive_mutex的使用
recursive_mutex字面理解递归互斥量。这个递归是什么意思呢?考虑std::mutex同一个线程连续调用lock的情况:一个线程lock后其他线程就会去等待这个线程unlock,但是这个线程又lock一下,自己等待自己不就死锁了(掉爆了->恢复->掉爆了0_o)?事实上如果一个线程连续两次调用mutex的lock的话会抛出异常。但一个项目可能会有很多函数都需要同步,那么多个函数嵌套调用的话基本避免不了多次lock,这个时候就可以使用recursive_mutex,他允许同一个线程多次调用lock,内部会记录调用lock次数,相应的也应该有对应次数的解锁(存疑?),使用和mutex一样,不贴代码了。
try_lock()的使用
mutex::try_lock
在不阻止的情况下尝试获取 mutex 的所有权。
bool try_lock();
返回值
如果方法成功获取的mutex所有权, 则为 true ; 否则为false。
备注
如果调用线程已拥有 mutex,则该行为不确定。
lock函数,如果另外一个线程已经lock了那么本线程就会一直阻塞在那里,直到获得所有权。有的时候我们不希望线程阻塞,而是希望获取不到所有权的时候执行另外一段代码,可以使用try_lock()比如:
void wait()
{
while (!s_mutex.try_lock())
{
// do somethings
}
}
如果获取所有权失败了,就做一些其他事情,然后再次尝试获取所有权。当然wait只是一个例子,不是正规用法。
另外不要想着用try_lock来代替std::recursive_mutex,备注中已经说了,如果本线程已经获取的所有权,再次try_lock会发生未定义行为(UB)。
smart_lock(RAII)
mutex要求lock/unlock必须成对出现,就好像new一个对象就要delete一样,这个时候很容易想到用智能指针管理,防止忘记delete。那么对于mutex我们当然可以自己定一个类,在构造的时候lock,析构的时候unlock,std提供了这样的类,所以就不用自己重新写了~~
1、std::lock_guard
// CLASS TEMPLATE lock_guard
template<class _Mutex>
class lock_guard
{ // class with destructor that unlocks a mutex
public:
using mutex_type = _Mutex;
explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{ // construct and lock
_MyMutex.lock();
}
lock_guard(_Mutex& _Mtx, adopt_lock_t)
: _MyMutex(_Mtx)
{ // construct but don't lock
}
~lock_guard() noexcept
{ // unlock
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
上述代码是windows下的一个std::lock_guard的实现,可以看到他在析构的时候unlock。同时他还提供一个构造时候不调用lock的构造函数,有时候也会用到。需要注意的是这个类没有拷贝/赋值构造函数,有右值构造,但是不建议使用,同时也不建议在堆上创建对象。示例:
/** 互斥量
*/
static std::mutex s_mutex;
/** 不带参数的线程回调
*/
void ThreadCallBack()
{
for (int i = 0; i < 100; ++i)
{
std::lock_guard<std::mutex> lockGuard(s_mutex);
ColorPrintf(Red, "ThreadCallBack\r\n");
}
}
int main()
{
std::thread td(ThreadCallBack);
for (int i = 0; i < 100; ++i)
{
std::lock_guard<std::mutex> lockGuard(s_mutex);
// 以下两种写法不建议使用
// std::lock_guard<std::mutex>&& lockGuard1(std::move(lockGuard));
// std::lock_guard<std::mutex>* p = new std::lock_guard<std::mutex>(s_mutex);
ColorPrintf(Green, "main\r\n");
}
td.join();
getchar();
return 1;
}
2、std::unique_lock
和lock_guard类似,也是提供在析构时unlock。但他的功能比lock_guard强大,主要是多了几个功能。
除了std::mutex和std::recursive_mutex,std 还提供了std::timed_mutex和std::recursive_timed_mutex ,他们比普通mutex多了两个函数try_lock_for、try_lock_until。
其中try_lock_for函数意思是在一段时间内尝试获取所有权,而不是获取所有权一段时间
代码如下:
#include <thread>
#include <mutex>
/** 互斥量
*/
static std::recursive_timed_mutex s_mutex;
/** 不带参数的线程回调
*/
void ThreadCallBack()
{
std::unique_lock<std::recursive_timed_mutex> lockGuard(s_mutex);
// 获取所有权后不释放
::Sleep(99999999);
ColorPrintf(Red, "ThreadCallBack\r\n");
}
int main()
{
std::thread td(ThreadCallBack);
{
// 让线程先获取所有权
::Sleep(1000);
std::unique_lock<std::recursive_timed_mutex>
lockGuard(s_mutex, std::defer_lock_t());
// 如果ThreadCallBack中的sleep时间小于下面的5000,tryRes就为true,否重为false
// 证明了try_lock_for是在一段时间内尝试获取所有权,而不是获取所有权一段时间
bool tryRes = lockGuard.try_lock_for(std::chrono::milliseconds(5000));
::Sleep(999999999);
}
td.join();
getchar();
return 1;
}
try_lock_until就是直到的语义,这里不在重复。
std::unique_lock比std::lock_guard强大的地方在于可以支持try_lock_for和try_lock_until。另外std::unique_lock是可以拷贝和复值构造的,但是最好还是别这要使用。
2、std::condition_variable
条件变量,一个生产者消费者模型。使用场景:某个线程等待其他线程准备(阻塞),另外线程准备好了就通知他(让他停止阻塞)。示例:
#include <thread>
#include <condition_variable>
/** 互斥量
*/
static std::mutex s_mutex;
static std::condition_variable s_con;
/** 不带参数的线程回调
*/
void ThreadCallBack()
{
// 不会死锁,考虑:
// 1、m_mutex.unlock();
// 2、wait()
// 3、m_mutex.lock();
// s_con.wait(lck) 相当于将1、2两步放到一个cpu周期内
std::unique_lock<std::mutex> lck(s_mutex);
s_con.wait(lck);
ColorPrintf(Red, "ThreadCallBack\r\n");
}
int main()
{
std::thread td(ThreadCallBack);
{
// 让线程先获取所有权
::Sleep(1000);
ColorPrintf(Green, "main\r\n");
s_con.notify_all();
}
td.join();
getchar();
return 1;
}
结果如下:
即便main函数sleep了一秒,但依然是绿色先输出。
但是
std::unique_lock<std::mutex> lck(s_mutex);
s_con.wait(lck);
比较奇怪,先lock再wait不就死锁了?
但是事实上不会死锁,考虑:
1、s_mutex.unlock();
2、wait() // 语义上的wait区别于s_con.wait(lck);
3、s_mutex.lock();
s_con.wait(lck);相当于将1、2两步放到一个cpu周期内,具体如何放到一个周期内可以搜索无锁编程(CAS)
注意:如果先notify然后在wait的话依然会等待。
3、std::atomic
使用简单,但是原理复杂,感兴趣可以搜索无锁编程(CAS)。
最后
本篇介绍了如何线程同步,下篇会介绍死锁。