介绍
遇到如下场景:
对于某个方法, 调用它时如果有另外一个线程正在调用该方法, 那么当前线程放弃调用, 但另外一个线程调用完毕之后必须再调用一次该方法.
具体例子:
多个线程更新一个数据结构(这个行为是否并发安全不在本次讨论中), 然后一旦结构发生修改就将数据同步到DB. 显然同步到DB是一个相对高耗时的行为, 而且短期对数据结构的更新显然可以压缩成一次DB的更新. 这也就是我上述说的场景了.
显然, 你可以加锁来解决. 但本文介绍一种wip技巧使用CAS(Compare And Set)来解决.
WIP技巧
wip = Working-In-Progress
这种模式我也不知道该怎么称呼, 但我在很多地方都有看过它的用法, 就叫它WIP吧.
RxJava2
ReactorProject
GRPC
3种技巧
- 随意选一个生产者作为消费者
- 使用线程池上的任意线程作为消费者(但每个时刻只会有一个消费者)
- 用一个固定的线程作为消费者(本质和2一样, 只要创建一个单线程的线程池即可)
这里只讨论前2种技巧.
public class Wip1 {
// 一般这种模式里都会有一个原子int变量用于cas竞争.
// 在某些变种里也会有 AtomicReference<Thread> 来表示. 本质是一样的.
private final AtomicInteger wip = new AtomicInteger();
// q是某个数据结构, 它的并发安全不在我们的讨论范围内, 这里假设它是一个ConcurrentLinkedQueue
private final ConcurrentLinkedQueue<Object> q = new ConcurrentLinkedQueue<>();
// 任意线程(以生产者的身份)可以调用该方法
public void produce(Object data) {
q.offer(data);
// (生产者)对数据结构做出修改之后, 立即调用drainLoop
// 当然你可以将drainLoop的概念暴露出去, 从而实现多次produce只调用一次drainLoop, 效率更高一些.
drainLoop();
}
private void drainLoop() {
// 多个线程(生产者)竞争wip, 第一个成功的线程获得执行权, 此后它变为消费者身份.
if (wip.getAndIncrement() != 0) {
return;
}
int delta = wip.get();
do {
// loop body: 有以下几个特征
// 1. 它肯定是被串行执行, 但可能在不同的线程上
// 2. 它一般包含一个循环, 处理一批元素: 这样做 1. 不会丢失元素(即元素在队列里, 但是没有处理它) 2.效率高
// 3. 处理过程不允许抛异常(必须catch掉), 否则drainLoop流程被意外中断, 此时处于一个中间错误状态: wip非0但实际没有线程在执行任务.
Object obj;
while ((obj = q.poll()) != null) {
// process obj
System.out.println(obj);
}
// 这个方法使得wip快速收敛到0, 并且不会错误地丢失对drainLoop的需求.
// wip/delta变量的用法是该wip模式实现的精髓, 需要细品.
delta = wip.addAndGet(-delta);
} while (delta != 0);
}
}
技巧2只需在技巧1的基础上改改即可.
public class Wip2 {
private static final Logger LOGGER = LoggerFactory.getLogger(Wip2.class);
private final Executor executor;
private final AtomicInteger wip = new AtomicInteger();
private final ConcurrentLinkedQueue<Object> q = new ConcurrentLinkedQueue<>();
public Wip2(Executor executor) {
this.executor = Objects.requireNonNull(executor);
}
public void produce(Object data) {
q.offer(data);
drainLoop();
}
private void drainLoop() {
// 这个判断不用放入线程池
if (wip.getAndIncrement() != 0) {
return;
}
// 抢到执行权之后将后续动作放入线程池即可
try {
executor.execute(this::drainLoop0);
} catch (RejectedExecutionException e) {
// TODO 一般是线程池关闭或队列满了, 可以考虑降级到当前线程执行任务, 否则当前进入一个错误的状态了, 即q里有元素, 但没有线程在处理它们; 另外wip也没有减成0
LOGGER.error("error", e);
drainLoop0();
}
}
private void drainLoop0() {
int delta = wip.get();
do {
Object obj;
while ((obj = q.poll()) != null) {
// process obj
System.out.println(obj);
}
// 这个方法使得wip快速收敛到0
delta = wip.addAndGet(-delta);
} while (delta != 0);
}
}
注意
实现时我们需要非常注意异常的处理, 如果处理元素时抛异常并且没有接住, 那么该wip就进入一个错误模式(wip非零缺没有线程正在处理, 队列非空缺没有线程正在处理(很可能需要等下一个元素才能跳出当前局面)). 最坏情况下还会导致整个wip模式止步不前.
异常的处理其实也很简单, 就是对于所有非wip技巧自身可控的因素都进行try/catch.
就我们上述2个例子而言, 就是指:
- obj元素的处理
- executor.execute()
q的话很多时候实现为wip技巧内部的一个MPSC队列, 因此不讨论它.
用途
- 任意方法(一般是执行很快的, 并且返回值是void(不立即需要结果), 类似fire-and-forget)串行化
- 对象生命周期方法串行化: 可以保证某个对象的生命周期方法都在同一个线程上执行(GRPC里就是这么用的)
- 攒批发送: 假设元素一个一个流入地产生, 而我们想一批一批地将他们写到DB, 也可以用这种方式, 当然你需要再加个定时器强制写出(否则当元素很稀疏时候无法保证实时性).
参考资料
- Disruptor
- JCTools