跳到正文
hello world

14. Semaphore:控制同一时间最多处理几个 PDF

发布于阅读量 1

前面几节讲的 synchronizedReentrantLock,核心都是“加锁”。

它们主要解决的是这种问题:

多个线程同时修改同一份共享数据,怎么保证结果不乱。

比如 count++、共享状态、共享集合这些。

这一节开始看另一个工具:Semaphore

它和锁有点像,但思路不太一样。

我现在更愿意把 Semaphore 理解成:

它不是为了保护某一个变量,而是为了控制同一时间最多允许几个线程进入某段代码。

放到 PDF 水印项目里,就是:

input 目录下可能有很多 PDF;
但我不想同时处理太多;
我希望同一时间最多只处理 3 个 PDF。

这个场景就很适合 Semaphore


为什么需要 Semaphore

如果我现在有 100 个 PDF,最直接的写法可能是:

for (File file : files) {
    new Thread(() -> {
        processPdf(file);
    }).start();
}

这样确实能把所有任务都启动起来。

但问题是,100 个 PDF 就会创建 100 个线程。

如果每个 PDF 都要读文件、生成水印、写新文件,压力不会小。

可能出现的问题包括:

线程太多,CPU 频繁切换;
同时读写太多 PDF,磁盘 IO 压力变大;
PDF 文件比较大时,内存占用上升;
程序看起来是并发了,但整体反而不稳定。

所以我不一定想让所有线程同时干活。

更合理的方式是:

可以创建多个任务;
但同一时间只允许 3 个任务真正进入 PDF 处理逻辑。

这就是 Semaphore 的作用。


Semaphore 可以理解成许可证

Semaphore 的核心概念是“许可证”。

比如:

Semaphore semaphore = new Semaphore(3);

意思是创建一个有 3 个许可证的信号量。

谁想执行受限制的代码,就先拿许可证:

semaphore.acquire();

执行完以后,再归还许可证:

semaphore.release();

可以简单理解成:

许可证有 3 个;
拿到许可证的线程才能进去处理 PDF;
没有拿到许可证的线程,就在外面等;
处理完 PDF 后,把许可证还回去;
等待的线程再继续进去。

这个思路其实挺直观。

就像一个停车场只有 3 个车位。

来了 10 辆车,不是 10 辆车都能进去。前 3 辆进去,后面的车只能等。等里面有车出来,外面的车才能进去。


先写一个简单例子

新建类:

com.succos.thread.SemaphoreStepDemo

代码如下:

package com.succos.thread;

import java.util.concurrent.Semaphore;

public class SemaphoreStepDemo {

    private static final Semaphore semaphore = new Semaphore(2);

    public static void main(String[] args) {

        for (int i = 1; i <= 5; i++) {

            int taskId = i;

            new Thread(() -> {

                boolean acquired = false;

                try {
                    System.out.println("任务 " + taskId + " 等待进入,线程:" + Thread.currentThread().getName());

                    semaphore.acquire();
                    acquired = true;

                    System.out.println("任务 " + taskId + " 开始执行,线程:" + Thread.currentThread().getName());

                    Thread.sleep(3000);

                    System.out.println("任务 " + taskId + " 执行完成,线程:" + Thread.currentThread().getName());

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();

                } finally {
                    if (acquired) {
                        semaphore.release();
                        System.out.println("任务 " + taskId + " 释放许可证,线程:" + Thread.currentThread().getName());
                    }
                }

            }, "task-thread-" + taskId).start();
        }
    }
}

这里我创建了 5 个线程。

但是信号量只有 2 个许可证:

private static final Semaphore semaphore = new Semaphore(2);

所以同一时间最多只有 2 个线程能进入真正的执行逻辑。


运行结果大概是什么样

运行后,输出可能类似这样:

任务 1 等待进入,线程:task-thread-1
任务 2 等待进入,线程:task-thread-2
任务 3 等待进入,线程:task-thread-3
任务 4 等待进入,线程:task-thread-4
任务 5 等待进入,线程:task-thread-5

任务 1 开始执行,线程:task-thread-1
任务 2 开始执行,线程:task-thread-2

等待 3 秒...

任务 1 执行完成,线程:task-thread-1
任务 1 释放许可证,线程:task-thread-1
任务 3 开始执行,线程:task-thread-3

任务 2 执行完成,线程:task-thread-2
任务 2 释放许可证,线程:task-thread-2
任务 4 开始执行,线程:task-thread-4

顺序不一定完全一样,但现象是一样的:

同一时间最多只有 2 个任务在执行。

其他任务虽然线程已经启动了,但它们会卡在:

semaphore.acquire();

等许可证。


acquire() 是等待许可证

这行代码很关键:

semaphore.acquire();

它的意思是:

我要拿一个许可证。

如果当前还有许可证,线程就能继续往下执行。

如果许可证已经被拿完了,当前线程就会在这里等待。

比如 Semaphore(2),前两个线程进来时:

初始许可证:2

线程1 acquire,许可证变成 1;
线程2 acquire,许可证变成 0;
线程3 再 acquire,没有许可证,只能等待。

等某个线程执行完,调用:

semaphore.release();

许可证数量才会增加,等待的线程才有机会继续执行。


release() 是归还许可证

release() 的意思是归还许可证。

比如:

semaphore.release();

它不是释放锁,而是把许可证还回去。

如果有线程正在 acquire() 那里等,就会有一个线程被唤醒,继续往下执行。

所以基本流程是:

进入之前 acquire;
执行完成 release。

这两个动作要成对出现。

如果只 acquire()release(),许可证会越来越少,最后其他线程可能一直等。

如果没拿到许可证却错误地 release(),许可证数量可能会被多释放,限制就失效了。

所以这也是为什么我在代码里写了:

boolean acquired = false;

为什么要写 acquired 标记

代码里有这一段:

boolean acquired = false;

try {
    semaphore.acquire();
    acquired = true;

    // 执行业务逻辑

} finally {
    if (acquired) {
        semaphore.release();
    }
}

这个写法不是多余的。

因为 acquire() 本身会抛 InterruptedException

如果线程在等待许可证时被中断了,acquire() 可能还没成功,代码就进入 finally

这时如果我直接写:

finally {
    semaphore.release();
}

就可能出现一个问题:

明明没有拿到许可证,却释放了一次许可证。

这样许可证数量就会被错误增加。

比如本来只有 2 个许可证,结果因为错误 release,可能变成 3 个、4 个。

这样 Semaphore 的并发控制就失效了。

所以我更稳妥的写法是:

boolean acquired = false;

semaphore.acquire();
acquired = true;

...

if (acquired) {
    semaphore.release();
}

只有确认拿到了许可证,才释放。


用 Semaphore 控制 PDF 并发数量

现在把这个思路放回 PDF 项目。

假设我希望:

可以给每个 PDF 创建一个任务;
但同一时间最多只允许 3 个 PDF 进入处理逻辑。

核心写法大概就是:

Semaphore semaphore = new Semaphore(3);

每个任务里:

semaphore.acquire();

try {
    // 处理 PDF
} finally {
    semaphore.release();
}

这样不管我启动了多少个线程,真正同时处理 PDF 的数量最多就是 3。


PDF 场景的简单代码

新建类:

com.succos.thread.SemaphorePdfDemo

先用 Thread.sleep() 模拟 PDF 处理:

package com.succos.thread;

import java.io.File;
import java.util.concurrent.Semaphore;

public class SemaphorePdfDemo {

    private static final Semaphore semaphore = new Semaphore(3);

    public static void main(String[] args) {

        File inputDir = new File("input");

        File[] files = inputDir.listFiles(file ->
                file.isFile() && file.getName().toLowerCase().endsWith(".pdf")
        );

        if (files == null || files.length == 0) {
            System.out.println("input 目录下没有 PDF 文件");
            return;
        }

        for (File file : files) {

            Thread thread = new Thread(() -> {

                boolean acquired = false;

                try {
                    System.out.println(file.getName() + " 等待处理,线程:" + Thread.currentThread().getName());

                    semaphore.acquire();
                    acquired = true;

                    System.out.println(file.getName() + " 开始处理,线程:" + Thread.currentThread().getName());

                    Thread.sleep(3000);

                    System.out.println(file.getName() + " 处理完成,线程:" + Thread.currentThread().getName());

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();

                } finally {
                    if (acquired) {
                        semaphore.release();
                        System.out.println(file.getName() + " 释放许可证,线程:" + Thread.currentThread().getName());
                    }
                }

            }, "pdf-thread-" + file.getName());

            thread.start();
        }
    }
}

这段代码的效果是:

每个 PDF 都对应一个线程;
但是同一时间最多只有 3 个线程真正执行处理逻辑;
其他线程会等在 acquire()。

这就比无限制同时处理要稳一些。


这段代码还缺什么?

上面的代码已经能限制并发数量,但它还不是很完整。

主要缺两个东西。

第一个是:主线程没有等待所有 PDF 处理完成。

也就是说,代码里没有 join(),也没有 CountDownLatch

如果后面我要统计总耗时、成功数量、失败数量,就需要等待所有任务结束。

第二个是:每个 PDF 一个线程,文件多了还是会创建很多线程。

Semaphore 只是限制了“同一时间有几个线程进入处理逻辑”,但它没有减少线程创建数量。

比如 1000 个 PDF:

还是会创建 1000 个线程;
只是同一时间只有 3 个线程真正处理 PDF;
剩下的线程都在 acquire() 那里等。

所以 Semaphore 能控制并发进入数量,但不能替代线程池。

这点要分清楚。


Semaphore 和线程池的区别

Semaphore 控制的是:

同一时间最多几个线程可以进入某段代码。

线程池控制的是:

创建多少线程;
任务怎么排队;
空闲线程怎么复用;
队列满了怎么办。

所以它们不是一回事。

在学习阶段,用 Thread + Semaphore 很适合观察并发控制。

但在真实项目里,我一般不会为每个 PDF 都手动创建线程,再靠 Semaphore 去拦。

更常见的方式是用线程池:

线程池本身就限制了工作线程数量;
任务多了进入队列等待;
线程处理完一个任务,再继续取下一个任务。

也就是说,后面学到 ThreadPoolExecutor 后,很多 PDF 批量处理场景会更适合用线程池。

不过 Semaphore 仍然有用。

比如线程池里某一段代码还要限制访问外部资源数量,就可以继续用它。


Semaphore 更适合限制资源访问

我现在觉得 Semaphore 特别适合控制“资源数量有限”的场景。

比如:

最多同时调用 3 个外部接口;
最多同时处理 2 个大文件;
最多允许 5 个任务访问某个模型服务;
最多允许 1 个线程执行某个 Python 脚本。

这种场景本质上不是要保护某个变量,而是资源有限。

资源只有几个,就用几个许可证。

比如:

private static final Semaphore PYTHON_SEMAPHORE = new Semaphore(1);

这就表示:

同一时间只允许一个线程执行 Python 相关逻辑。

这种写法在调用外部脚本、模型推理、GPU 任务时挺常见。


Semaphore 和 Lock 的区别

到这里,Semaphore 和锁的区别就比较清楚了。

ReentrantLock 一般是:

同一时间只允许一个线程进入;
主要保护共享数据。

Semaphore 可以是:

同一时间允许 N 个线程进入;
主要控制资源访问数量。

比如:

new Semaphore(1);

效果有点像锁,同一时间只允许一个线程进入。

但:

new Semaphore(3);

就和普通锁不一样了,它允许 3 个线程同时进入。

所以我不会简单把 Semaphore 当成锁。

它更像是一个“并发数量阀门”。


这一节小结

这一节我主要记住几点:

1. Semaphore 用来控制同一时间最多允许几个线程进入某段代码;
2. acquire() 是获取许可证,拿不到就等待;
3. release() 是归还许可证;
4. acquire 和 release 要成对出现;
5. 最好用 acquired 标记,避免没拿到许可证却错误释放;
6. Semaphore 能限制并发进入数量,但不能减少线程创建数量;
7. PDF 批量处理最终更适合线程池,但 Semaphore 很适合理解并发限制。

用一句话总结:

Semaphore 不是用来保护某个变量的,它更像是在入口处放了几个许可证,谁拿到谁进去。

下一节继续写一个更完整的 Semaphore 实战版本。

重点是:限制 PDF 并发处理数量,同时让主线程知道所有任务什么时候结束。

14. Semaphore:控制同一时间最多处理几个 PDF - Java并发之从Thread到CompletableFuture - 上下文网