系统编程之高效同步机制:条件变量

以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「ENG八戒」https://mp.weixin.qq.com/s/zy6Dmo_b3xMPPEO3HNxuuw

有一段时间没碰条件变量【condition variable】,快忘了它到底是啥。大概记得,之前是用来写底层接口,辅助实现安全的生产消费模式等等。

下面让我们来探讨一下条件变量的是非,简单起见接下来的所有接口函数和代码都基于 linux C。

用途

一般数据的生产消费或者相关业务逻辑分布在不同的线程中,如果他们的执行顺序是有条件触发的,那么就需要用到条件变量了。

条件变量允许系统把条件变量所在的线程 A 挂起,就是说条件变量阻塞了当前线程的执行,直到在其它线程中通过相同的条件变量唤醒线程 A.

这有点像俩小朋友在玩你追我赶的游戏,你不追过来,我就不动。

再比如,涉及到多线程的应用中,线程结束后资源是否会被自动回收,有赖于线程的属性配置。如果需要在一个线程里连接(join)另一个线程并获取信息,那么这个线程会被阻塞直到另一个线程结束。这种连接机制需要等待线程结束,所以也属于条件变量的特殊应用。

创建和销毁

使用 pthread_cond_t 类型定义的条件变量需要使用 pthread_cond_init 初始化。

int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);

使用 pthread_cond_init 初始化条件变量时,可以传入 pthread_condattr_t 类型变量指定条件变量的属性,如果属性是默认值可以传入 NULL,或者直接使用宏定义 PTHREAD_COND_INITIALIZER 代替 pthread_cond_init(此时,条件变量必须是静态变量)

static pthread_cond_t cond_variable = PTHREAD_COND_INITIALIZER;

条件变量在使用完毕后应该使用 pthread_cond_destroy 释放占用的资源。

int pthread_cond_destroy(pthread_cond_t *cond);

有个小细节,如果条件变量被分配在线程的栈上,该线程会维护一个条件变量的列表,那么在该线程被终止前必须先释放条件变量所占用的资源(调用 pthread_cond_destroy),否则会产生 memory corrupted 类似的错误。

等待和唤醒

条件变量说到底就是线程之间同步机制的众多方式中的一种,但是在使用过程中,必须搭配使用互斥锁。

为什么必须搭配使用互斥锁?

基本范式

先来看看一般的使用方式,比如,实现的数据队列中,推入数据的接口函数

void queue_push(void *data)
{
    pthread_mutex_lock(mutex);

    // 往队列中推入数据 data
    // ...

    pthread_cond_signal(cond_variable);

    pthread_mutex_unlock(mutex);
}

接着,提取数据的接口函数

void *queue_pop()
{
    pthread_mutex_lock(mutex);

    while (wait_condition) {
        pthread_cond_wait(cond_variable, mutex);
    }

    // 从队列中弹出数据 data
    // ...

    pthread_mutex_unlock(mutex);
}

上面代码中 cond_variable 是已初始化的条件变量。mutex 同样是经过初始化的互斥锁,类型是 pthread_mutex_t。wait_condition 是条件表达式,布尔类型,用于判断是否进入条件变量的等待。

提取数据的函数接口代码中,开始判断条件表达式之前,先占用互斥锁。当条件表达式为真,条件变量进入等待状态并且释放互斥锁(这是原子操作),所在线程就会被挂起,直到被其它线程通过条件变量 cond_variable 唤醒。唤醒后,再次尝试占用互斥锁,然后执行后续的数据处理(从队列中提取数据),在数据处理完成后释放互斥锁。

可以看到,条件表达式的使用要素有三个:条件变量、起保护作用的锁、仅起判断作用的条件表达式。

效能提升

其实,如果为了同步数据,单纯用锁也是能实现的,但是会长期占用系统资源,效率太低。比如下面把提取数据的接口函数写成

void *queue_pop_2()
{
    pthread_mutex_lock(mutex);

    while (wait_condition) {
        sleep(1);
    }

    // 从队列中弹出数据 data
    // ...

    pthread_mutex_unlock(mutex);
}

可见,如果仅用锁来实现接口,在每次提取数据之前都会空转固定的时间。如果数据队列中已经准备好数据,那么提取数据的操作需要等待最长可达一个周期(示例代码是 1 秒)。

条件变量和锁的配合充分利用了系统的能力,大大降低性能损耗,避免长时间占用锁。但要注意,使用锁不是为了保护条件变量自身,而是为了保护条件表达式的判断,防止在判断之后和条件变量进入等待状态之前,其它线程修改条件而导致判断失效,以及对目标数据逻辑的序列化执行(也就是同步)。

while (wait_condition) {
    pthread_cond_wait(cond_variable, mutex);
}

细看这段代码,发现唤醒后(也就是在条件变量退出等待和重新占用互斥锁之后),还要再次执行条件表达式的判断。这是因为唤醒之后等待的条件可能会被其它线程变更,为了安全起见需要重新检查条件,如果等待的条件为真,就再次进入等待状态直到下次被唤醒。

等待

条件变量等待的方式有两种,一种是持续等待,直到被唤醒

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

如果一直处于等待状态,如何退出?

pthread_cond_wait 提供了线程取消的功能,可通过 pthread_cancel 退出指定线程。

另一种是条件变量进入等待后,开始计时,在计时结束后仍然未被唤醒则主动退出等待并返回错误信息。

int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime);

计时结束指的是到达指定时刻。

唤醒

关于唤醒同样也有两种方式,其一是,仅唤醒一个正在等待的条件变量

int pthread_cond_signal(pthread_cond_t *cond);

如果有多个正在等待的条件变量,那么最终被唤醒的线程由系统调度策略确定。

其二是,唤醒所有正在等待的条件变量,这种方式非必要不使用,因为会对系统带来不必要的运行消耗,被称为惊群效应

int pthread_cond_broadcast(pthread_cond_t *cond);

希望以上的内容对你有所帮助,也欢迎联系我一起探讨


热门相关:总裁的秘密爱人   魔神狂后   楚氏赘婿   我成了暴戾帝君的小娇包   慕少,你老婆又重生了