C++ 线程 线程同步

当多个线程或者进程同时访问公共资源的时候就需要同步。比如说一个线程准备访问堆上的一块内存,另外的一个线程把他给释放了但是还没有来得及设置为空,那么就会出现问题,这个时候就应该线程同步,比如让第一个线程判空访问完了,第二个线程在释放置空;或者让第二个线程释放置空完了,第一个线程在进行判空访问。

简单的说线程同步就是将原本在同一时间访问同一资源的线程,让他们有时间顺序的访问资源,即确保公共资源不被同时访问。但是并不是所有的公共资源都需要同步,比如两个线程都是去只读不写某块内存,或者是具有原子性的公共资源等;加速线程则通过设计算法上避开了同时访问资源,比如多线程处理一个数组,可以第一个线程处理数组的一块,第二个线程处理数组的另一块,从而避免公共内存资源访问。

有多线程处理了公共资源的时候才需要线程同步。这里公共资源不仅仅指堆内存,文件,句柄,端口,管道,共享内存等只要能够被多个线程同时操作的都是公共资源。

线程同步的方法

在windows下,一般会有以下几种:

  1. 事件(Event);
  2. 信号量(semaphore);
  3. 互斥量(mutex);
  4. 临界区(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. 设置前景颜色
  2. 输出
  3. 将前景颜色设置回去

当子线程运行完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)。

最后

本篇介绍了如何线程同步,下篇会介绍死锁。

 

  • 1
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值