1. 多线程交替打印问题解析
今天我们来深入探讨一个经典的多线程同步问题 - LeetCode 1115题"交替打印FooBar"。这个问题看似简单,却涵盖了多线程编程中的核心概念和常见陷阱。作为在并发编程领域摸爬滚打多年的老手,我将带大家从原理到实践全面剖析这个问题的解决方案。
1.1 问题描述与核心挑战
题目要求我们实现一个FooBar类,该类有两个方法foo()和bar(),分别由两个不同的线程调用。foo()方法会打印"foo",bar()方法会打印"bar"。我们需要确保这两个方法交替执行,输出结果为"foobarfoobar...",共重复n次。
这个问题的核心挑战在于线程同步。在多线程环境下,我们无法预知操作系统会如何调度线程执行顺序。如果不加控制,可能会出现"foofoobar"、"barbarfoo"等不符合要求的输出。因此,我们需要一种机制来确保两个线程严格按照foo-bar-foo-bar的顺序交替执行。
1.2 同步原语的选择
解决这类线程同步问题,我们通常会考虑以下几种同步原语:
- 互斥锁(Mutex):最基本的同步机制,保证同一时间只有一个线程能访问共享资源
- 条件变量(Condition Variable):允许线程在某个条件不满足时主动等待,直到其他线程通知条件可能已改变
- 信号量(Semaphore):控制对共享资源的访问数量
- 屏障(Barrier):使多个线程在某个点同步等待
对于交替打印这种需要严格顺序控制的场景,条件变量配合互斥锁是最合适的选择。它不仅能保证互斥访问,还能让线程在条件不满足时高效等待,避免忙等消耗CPU资源。
2. 解决方案深度解析
2.1 代码结构与核心逻辑
让我们先看一下完整的解决方案代码:
cpp复制class FooBar {
private:
int n;
mutex mtx;
condition_variable cv;
int turn = 0;
public:
FooBar(int n) {
this->n = n;
}
void foo(function<void()> printFoo) {
unique_lock<mutex> lock(mtx);
for (int i = 0; i < n; i++) {
cv.wait(lock, [this]{return turn == 0;});
printFoo();
turn = 1;
cv.notify_one();
}
}
void bar(function<void()> printBar) {
unique_lock<mutex> lock(mtx);
for (int i = 0; i < n; i++) {
cv.wait(lock, [this]{return turn == 1;});
printBar();
turn = 0;
cv.notify_one();
}
}
};
2.2 关键组件解析
- 互斥锁(mtx):保护共享变量turn的访问,防止竞态条件
- 条件变量(cv):协调两个线程的执行顺序
- turn变量:表示当前应该由哪个线程执行(0表示foo,1表示bar)
2.3 执行流程详解
让我们以n=2为例,详细跟踪程序的执行流程:
- 初始化:turn = 0
- foo线程获取锁,检查turn==0为真,执行printFoo()
- foo线程设置turn=1,通知等待的线程
- bar线程被唤醒,检查turn==1为真,执行printBar()
- bar线程设置turn=0,通知等待的线程
- foo线程再次被唤醒,检查turn==0为真,执行printFoo()
- foo线程设置turn=1,通知等待的线程
- bar线程被唤醒,检查turn==1为真,执行printBar()
- 两个线程都完成循环,退出
2.4 条件变量的使用技巧
条件变量的wait操作实际上做了三件事:
- 释放互斥锁(允许其他线程获取锁)
- 等待通知(线程挂起,不消耗CPU)
- 被通知后重新获取锁
cv.wait(lock, predicate)这种形式是条件变量的推荐用法,它等价于:
cpp复制while(!predicate()) {
cv.wait(lock);
}
这种写法能有效防止虚假唤醒(spurious wakeup)问题,即线程可能在没有收到通知的情况下被唤醒。
3. 常见问题与调试技巧
3.1 死锁风险与避免
在多线程编程中,死锁是最令人头疼的问题之一。在这个解决方案中,我们需要注意以下几点来避免死锁:
- 锁的获取顺序:确保所有线程以相同的顺序获取锁
- 锁的作用域:使用unique_lock自动管理锁的生命周期
- 通知时机:确保在修改共享状态后再发送通知
提示:如果在调试时发现程序挂起,首先检查是否有线程在等待条件变量但未被唤醒,或者唤醒的线程条件不满足导致继续等待。
3.2 性能考量
虽然这个解决方案能正确解决问题,但在高并发场景下可能有性能瓶颈:
- 锁竞争:两个线程频繁竞争同一个锁
- 上下文切换:线程频繁挂起和唤醒带来的开销
对于简单的交替打印场景,这个方案已经足够。但在更复杂的生产环境中,可能需要考虑更高效的同步机制,如无锁编程或更细粒度的锁策略。
3.3 扩展思考
如果将问题扩展为三个线程交替打印(如foo-bar-baz),该如何修改这个解决方案?
关键点在于:
- 增加turn的可能取值(0,1,2)
- 每个线程等待自己的turn值
- 设置下一个线程的turn值
- 使用notify_all()而不是notify_one(),因为可能有多个线程在等待
4. 替代方案比较
4.1 使用两个信号量
cpp复制class FooBar {
private:
int n;
sem_t foo_sem;
sem_t bar_sem;
public:
FooBar(int n) {
this->n = n;
sem_init(&foo_sem, 0, 1);
sem_init(&bar_sem, 0, 0);
}
void foo(function<void()> printFoo) {
for (int i = 0; i < n; i++) {
sem_wait(&foo_sem);
printFoo();
sem_post(&bar_sem);
}
}
void bar(function<void()> printBar) {
for (int i = 0; i < n; i++) {
sem_wait(&bar_sem);
printBar();
sem_post(&foo_sem);
}
}
};
这种方案更简洁,但信号量的抽象级别较高,可能隐藏了一些底层细节,不利于理解线程同步的基本原理。
4.2 使用原子变量和自旋锁
cpp复制class FooBar {
private:
int n;
atomic<int> turn{0};
public:
FooBar(int n) {
this->n = n;
}
void foo(function<void()> printFoo) {
for (int i = 0; i < n; i++) {
while (turn != 0) {}
printFoo();
turn = 1;
}
}
void bar(function<void()> printBar) {
for (int i = 0; i < n; i++) {
while (turn != 1) {}
printBar();
turn = 0;
}
}
};
这种方案使用了忙等待(busy-waiting),虽然避免了锁的开销,但会持续消耗CPU资源,通常不推荐在实际应用中使用。
4.3 方案选择建议
- 学习目的:推荐使用条件变量方案,能深入理解线程同步机制
- 生产环境:信号量方案更简洁可靠
- 性能关键:可能需要考虑更高级的无锁数据结构
在实际项目中,选择同步机制时需要权衡:
- 正确性(最重要)
- 可读性和可维护性
- 性能需求
- 平台兼容性
5. Java实现对比
虽然原始问题是C++实现,但作为Java开发者,我们可以看看对应的Java实现:
java复制class FooBar {
private int n;
private Lock lock = new ReentrantLock();
private Condition fooCondition = lock.newCondition();
private Condition barCondition = lock.newCondition();
private boolean isFooTurn = true;
public FooBar(int n) {
this.n = n;
}
public void foo(Runnable printFoo) throws InterruptedException {
for (int i = 0; i < n; i++) {
lock.lock();
try {
while (!isFooTurn) {
fooCondition.await();
}
printFoo.run();
isFooTurn = false;
barCondition.signal();
} finally {
lock.unlock();
}
}
}
public void bar(Runnable printBar) throws InterruptedException {
for (int i = 0; i < n; i++) {
lock.lock();
try {
while (isFooTurn) {
barCondition.await();
}
printBar.run();
isFooTurn = true;
fooCondition.signal();
} finally {
lock.unlock();
}
}
}
}
Java的实现与C++类似,但有几点不同:
- 使用ReentrantLock代替mutex
- 可以创建多个Condition对象,提高可读性
- 必须显式处理InterruptedException
- 锁的释放放在finally块中确保异常安全
6. 实际应用场景
这种交替执行的模式在实际开发中有许多应用场景:
- 生产者-消费者问题:一个线程生产数据,另一个线程消费数据
- 管道处理:多个处理阶段按顺序处理数据
- 协作任务:多个线程需要按特定顺序协作完成任务
理解并掌握这种同步模式,对于开发高效、可靠的多线程应用至关重要。我在实际项目中就曾用类似的模式实现过日志系统的异步写入,其中一个线程负责收集日志,另一个线程负责写入文件,两者通过条件变量同步,既保证了日志顺序,又提高了系统性能。