OS-并发-同步

条件变量

条件变量是一种同步机制,用于线程间的通信。当某些执行状态不满足时,线程可以将自己加入到等待队列中,直到该条件发生并被唤醒。条件变量有两个主要操作

  • wait():线程调用此函数进入睡眠状态,等待某个条件的发生。
  • signal():线程调用此函数通知等待的线程,某个条件已经发生。

考虑以下程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int done=0;

mutex_t m=MUTEX_INITIALIZER;
cond_t c=COND_INITIALIZER;

void thr_exit(){
    mutex_lock(&m);
    done=1;
    cond_signal(&c);
    mutex_unlock(&m);
}

void* child(void*arg){
    // do something.
    thr_exit();
    return NULL;
}

void thr_join(){
    mutex_lock(&m);
    while(done==0){
        cond_wait(&c,&m)
    }
    mutex_unlock(&m);
}

int main(){
    pthread_t p;
    pthread_create(&p,NULL,child,NULL);
    thr_join();
    return 0;
}

有几点值得注意:

  • 为什么 cond_wait(&c,&m) 需要获取锁:

因为 wait 将导致线程睡眠,wait 需要负责处理锁的释放。当等待的条件满足并被唤醒后,wait 会重新获取锁 m,确保线程在继续执行前持有锁。

  • 为什么需要一个 done 变量:

假设没有 done 变量,程序变成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void thr_exit(){
    mutex_lock(&m);
    cond_signal(&c);
    mutex_unlock(&m);
}

void* child(void*arg){
    // do something.
    thr_exit();
    return NULL;
}

void thr_join(){
    mutex_lock(&m);
    cond_wait(&c,&m)
    mutex_unlock(&m);
}

如果 main 创建子线程后。子线程立刻运行并执行 signal。此时没有等待唤醒的线程,然后子线程退出,main 进入 join 函数后将永远等待信号的唤醒。

  • 为什么使用 wait&join 需要持有锁:

假设不使用锁保护信号的发出和获取,当 main 进入睡眠前的瞬间,子线程发出了信号并发现等待睡眠的队列为空,然后 main 将持续睡眠,再也没有信号将其唤醒。

为了保证安全,在调用 wait&join 时务必持有锁。

生产者和消费者

生产者和消费者模型描述了两类线程或进程:一类线程或进程持续向缓冲区写入数据(生产者),另一类线程或进程持续从缓冲区读取数据(消费者)。

现在可以思考另一个问题:为什么需要使用循环来检查变量而不是 if?

1
2
3
    while(done==0){
        cond_wait(&c,&m)
    }

我们假设有多个消费线程(Tc1,Tc2):

假设 Tc1 检查到缓冲区为空并进入等待状态,Tp 放入数据并发出信号。问题的关键在这:在 Tc1 被唤醒前,Tc2 刚好开始消费数据并清空了缓冲区,接着 Tc1 才被唤醒并开始消费数据。但此时缓冲区已经为空,Tc1可能会崩溃。

又或者条件变量控制了多个等待中的线程,此时因为调度策略而先被唤醒的线程将消费数据,后被唤醒的线程将面对一个空的缓冲区,这种情况被称为虚假唤醒(Spurious Wakeups)

sequenceDiagram
    participant Tc1 as 消费者Tc1
    participant Tp as 生产者Tp
    participant Tc2 as 消费者Tc2
    participant Buffer as 缓冲区

    Tc1->>Buffer: 检查缓冲区(空)
    Tc1->>Buffer: 进入等待状态(cond_wait)
    Tp->>Buffer: 获取锁,写入数据(count=1)
    Tp->>Buffer: 发送信号(cond_signal)
    Tp->>Buffer: 释放锁
    Tc2->>Buffer: 获取锁,检查缓冲区(满)
    Tc2->>Buffer: 消费数据(count=0)
    Tc2->>Buffer: 释放锁
    Tc1->>Buffer: 被唤醒,重新获取锁
    Tc1->>Buffer: 检查缓冲区(空)
    Tc1-->>Buffer: 尝试消费空缓冲区(崩溃风险)

因此,使用 while 以确保在被唤醒后再次检查是十分有必要的。在此提出一个编码习惯:

在条件变量同步中,必须使用 while 循环而非 if 语句来检查条件。原因如下:

  • 虚假唤醒(Spurious Wakeups):某些线程库(如POSIX)允许条件变量在未被显式唤醒时因系统原因返回,即使条件未满足。
  • 状态竞态(State Race):线程被唤醒后,共享状态可能已被其他线程修改(例如,生产者唤醒消费者,但数据已被其他消费者抢先消费)。
  • Mesa语义特性:条件变量的唤醒仅表示状态可能发生了变化,而非绝对满足条件。

总是使用循环,还可以为广播信号做好准备。

当多个线程等待不同条件时(如内存不足),广播(pthread_cond_broadcast 是确保正确性的关键:

  • 场景示例
    • 线程A申请100字节,线程B申请50字节,均因内存不足等待。
    • 线程C归还50字节后,若使用 pthread_cond_signal,可能仅唤醒一个线程(如A),但A仍需100字节(仍不满足),导致线程B无法被唤醒。
  • 解决方案:通过广播唤醒所有等待线程,每个线程通过 while 循环重新检查自身条件。

同步

同步的本质是通过happens-before关系控制事件的顺序,确保多个进程或线程的协调执行。例如,代码中隐含的依赖关系(如 c = a + bb 必须在 c 之前执行)需要通过同步原语(如锁、条件变量)显式管理,以避免竞态条件。

同步问题可抽象为以下两类:

  1. 原子性控制:通过互斥锁确保临界区代码的独占访问,防止数据不一致。
  2. 条件等待:利用条件变量实现线程在特定条件未满足时休眠,并在条件满足时被唤醒。

条件等待的万能框架:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 等待线程
mutex_lock(&lock);
while (!cond()) {  // 循环检查避免虚假唤醒
    cond_wait(&cv, &lock);  // 释放锁并休眠
}
assert(cond());  // 条件成立后继续执行
mutex_unlock(&lock);

// 唤醒线程
mutex_lock(&lock);
update_condition();  // 修改条件
cond_broadcast(&cv);  // 唤醒所有等待线程
mutex_unlock(&lock);

在解决同步问题时,关键在于理解 “同步成功” 的条件是什么。然后各个线程在条件不满足时等待,直到条件满足方可继续。

首先,代码天然存在一个执行顺序和依赖关系:

1
2
3
a=1
b=int(input())
c=a+b

这个简单的程序里 c 必须要在 b 之后执行。但实际任务中,往往存在可以并行的部分。如果将每一个任务抽象为一个节点或函数,绘制依赖图,基本上呈现一个树状结构,从根节点开始,一层一层向下扩展,层与层之间存在依赖,但每一层内部往往可以并行处理。

graph TD
    subgraph 层级1
        A[根任务]
    end
    
    subgraph 层级2
        B1[子任务B1]
        B2[子任务B2]
        B3[子任务B3]
    end
    
    subgraph 层级3
        C1[子任务C1]
        C2[子任务C2]
        C3[子任务C3]
        C4[子任务C4]
    end
    
    subgraph 层级4
        D1[叶子任务D1]
        D2[叶子任务D2]
    end

    A --> B1
    A --> B2
    A --> B3
    
    B1 --> C1
    B1 --> C2
    B2 --> C3
    B3 --> C4
    
    C1 --> D1
    C2 --> D1
    C3 --> D2
    C4 --> D2
updatedupdated2025-04-172025-04-17