目录

题目描述

1115. 交替打印 FooBar

题意分析

同一个对象上会有两个线程并发跑起来,一个只负责调用 printFoo,另一个只负责调用 printBar,各自调用 n 次。要求不管操作系统怎么调度,最终拼出来的输出永远是 foobar 重复 n 次。

关键约束信号是「两个方法体互不相干,却必须产生一个全局有序的输出」。这说明输出顺序不可能由各自循环的计数决定,只能由两段代码谁先跑到打印那一行决定。

还有一个隐含约束:两个方法被启动的先后是不确定的,bar 的线程完全可能先被调度。所以初始状态必须自带方向性,让抢先跑起来的 bar 也只能停下来等。

边界上 n 至少为 1,不存在一次都不打印的情况;结束时两个线程都必须能正常退出,不能有任何一方永远卡在等待里。

解法:信号量交替放行

核心思路

最直觉的写法是加一个共享布尔量 fooTurn,两边都写 while (!fooTurn) {} 这样的自旋等待。逻辑上说得通,但瓶颈很明显:忙等会把 CPU 烧满,而且如果这个变量不是 volatile 的,另一个线程可能永远读到过期的缓存值,直接死循环。

沿着这个思路再想一步:忙等之所以出现,是因为「等待」这件事被表达成了「反复查看」。如果换成一种能让线程真正睡下去、并由对方主动叫醒的机制,就既省 CPU 又天然保证了可见性。

观察真正需要维护的东西:任意时刻,只有一个线程被允许推进到打印那一行。把这个「允许」做成一枚可以被交出去的令牌,问题就变成令牌在两个线程之间来回传递。

于是引入两个计数信号:fooSem 表示 foo 手上有几张通行证,barSem 同理。不变量是 fooSem + barSem ≡ 1,即整个生命周期里通行证总数恒为 1,永远只有一边能走。初始把这唯一一张给 foo,方向性就有了;每次打印完把它交给对方,交替就自动成立了。

解题步骤

  • 构造对象时保存 n。因为两个方法都要靠它决定循环次数,且它在并发期间只读不写,不需要额外保护。
  • fooSem 初始化为 1barSem 初始化为 0。这一步就是把唯一的通行证预先发给 foo,从而钉死第一个输出是 foo,与线程启动顺序无关。
  • foo() 循环 n 次:先 fooSem.acquire(),拿不到就阻塞挂起;拿到后执行 printFoo.run();最后 barSem.release() 把通行证交给对方。释放必须放在打印之后,否则对方可能在自己打印完成前就插进来。
  • bar() 循环 n 次:先 barSem.acquire(),打印,再 fooSem.release()。这样通行证又回到 foo 手里,开启下一轮。
  • 两个循环次数都是 n,保证第 nbar 打印完后,foo 虽然收到了最后一张通行证但循环已经结束,两个线程都干净退出,不会残留阻塞。

n = 2 走一遍:初始 fooSem = 1barSem = 0。假设调度器先让 bar 跑,bar 执行 barSem.acquire()barSem = 0,立即阻塞。foo 跑起来,acquire 成功,fooSem0,输出 foo,然后 barSem.release() 使 barSem = 1,此时 fooSem + barSem = 1 仍然成立。被唤醒的 bar 拿走许可,barSem 回到 0,输出 bar,再 fooSem.release() 使 fooSem = 1。第二轮同理:foo 拿到许可打印 foofooSem = 0barSem = 1bar 打印 barbarSem = 0fooSem = 1。两个循环各走满两次退出,最终输出 foobarfoobar,末尾 fooSem 里剩下的那张许可无人认领,也不影响退出。

代码实现

class FooBar {
    private final int n;
    private final java.util.concurrent.Semaphore fooSem = new java.util.concurrent.Semaphore(1);
    private final java.util.concurrent.Semaphore barSem = new java.util.concurrent.Semaphore(0);

    public FooBar(int n) {
        this.n = n;
    }

    public void foo(Runnable printFoo) throws InterruptedException {
        for (int i = 0; i < n; i++) {
            // foo 打印后把执行权交给 bar。
            fooSem.acquire();
            printFoo.run();
            barSem.release();
        }
    }

    public void bar(Runnable printBar) throws InterruptedException {
        for (int i = 0; i < n; i++) {
            // bar 打印后把执行权交回下一轮 foo。
            barSem.acquire();
            printBar.run();
            fooSem.release();
        }
    }
}
type FooBar struct {
    n      int
    fooSem chan struct{}
    barSem chan struct{}
}

func Constructor(n int) FooBar {
    fb := FooBar{
        n:      n,
        fooSem: make(chan struct{}, 1),
        barSem: make(chan struct{}, 1),
    }
    fb.fooSem <- struct{}{}
    return fb
}

func (fb *FooBar) Foo(printFoo func()) {
    for i := 0; i < fb.n; i++ {
        // foo 打印后把执行权交给 bar。
        <-fb.fooSem
        printFoo()
        fb.barSem <- struct{}{}
    }
}

func (fb *FooBar) Bar(printBar func()) {
    for i := 0; i < fb.n; i++ {
        // bar 打印后把执行权交回下一轮 foo。
        <-fb.barSem
        printBar()
        fb.fooSem <- struct{}{}
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,两个方法各自循环 n 次,每次只做一对常数时间的获取与释放,没有任何重试或轮询。
  • 空间复杂度:$O(1)$,只额外持有两个信号量对象,占用与 n 无关。

关键点总结

  • 并发题的核心是先写出不变量再写代码。这里的不变量「通行证总数恒为 1」一旦确定,初始值、获取顺序、释放位置就全被推导出来了,不需要靠试。
  • 初始状态承担了「谁先跑」的定序职责。凡是要求固定首个输出的交替问题,都要把初始许可只发给指定的一方。
  • 释放对方的信号量必须是临界区的最后一步,把打印夹在获取和释放之间,才能保证输出不交错。
  • 面试视角:面试官通常会追问「为什么不用 synchronizedwait/notify」。可以答:能实现,但两线程场景下 notify 唤醒的一定是对方线程所以恰好安全,扩展到三线程就必须换 notifyAll 并配合状态变量重新检查,而信号量把这个状态显式化了,更不容易写错。
  • 面试视角:还要能主动说明「忙等 + 非 volatile 变量」的写法为什么错,这一点比写出正确答案更能体现对内存可见性的理解。

易错点总结

  • 错误写法barSem 也初始化为 1。用例 n = 1bar 线程可能先拿到许可,输出变成 barfoo,交替顺序从第一轮就崩了。
  • 错误写法:把 printFoo.run() 写在 barSem.release() 之后。用例 n = 2bar 可能在 foo 真正打印前就完成打印,出现 barfoo 这类乱序。
  • 错误写法foo 里释放的是 fooSem。用例 n = 1foo 反复拿回自己的许可连打 nfoobar 永远阻塞,程序超时。
  • 错误写法:用一个共享的普通 boolean 字段自旋等待,不加 volatile。用例任意 n → 另一线程可能一直读到旧值,形成活锁式死循环,同时 CPU 被打满。
  • 错误写法bar 的循环写成 n - 1 次或用 i <= n。用例 n = 3 → 少打印一个 bar 或多阻塞一轮,输出长度不对甚至挂死。
  • 错误写法:把 acquire 放在循环外,只获取一次。用例 n = 2 → 第二轮不再等待对方,两个线程各自连续打印,输出退化成 foofoobarbar
  • 错误写法:Go 版本忘记在构造函数里往 fooSem 里塞初始值。用例任意 n → 两个 goroutine 都在接收操作上阻塞,直接死锁。
  • 错误写法:Go 版本把通道建成无缓冲的,同时保留「先接收后发送」的结构。用例任意 n → 发送方要等接收方就位,foo 打印完发往 barSem 时若 bar 尚未到达接收点就会被挂住,配合初始塞值的逻辑就无处安放,容易写出死锁。

相似题目

题目 难度 考察点
1114. 按序打印 简单 三个线程一次性定序,无需循环回传
1116. 打印零与奇偶数 中等 三线程按 0x0y 模式轮转,令牌分支更多
1195. 多线程 Fizz Buzz 中等 放行对象由数字整除关系动态决定
1226. 哲学家进餐 中等 多资源竞争下的死锁避免
1242. 多线程网络爬虫 中等 线程池与共享去重集合的并发安全