Kafka 在文档中注明了 Consumer 不是线程安全的,意味着一个消费者只能对应一个线程,Consumer 被并发调用时会出现不可预期的结果。因此kafka实现了并发检测,在发生并发时直接抛出异常。
在Consumer的订阅函数和拉取消息函数中都用到了acquireAndEnsureOpen函数来获取锁,并且通过try-finally中的release函数来释放锁,作用就是保护Consumer中的方法只能单线程调用。
可重入锁和不可重入锁
可重入锁
也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。换一种说法:同一个线程再次进入同步代码时,可以使用自己已获取到的锁。
实现方式:为每个锁关联一个获取计数器和一个所有者线程,当计数值为0时,这个锁就被认为是没有被任何线程所占有的。当线程请求一个未被持有的锁时,计数值将会递增。而当线程退出同步代码时,计数器会相应地递减。当计数值为0时,则释放该锁。
作用:防止在同一线程中多次获取锁而导致死锁发生。
Consumer中使用的锁为可重入锁。
不可重入锁
这个不可重入是针对同一个线程能否第二次获取锁。
不可重入锁,指一个线程在获取到锁并且没有释放之前,不可以再次获取锁。
并发检测的实现源码分析
代码位置:clients/src/main/java/org/apache/kafka/clients/consumer/KafkaConsumer.java
Consumer中使用了参数currentThread和refcount,分别表示当前持有锁的线程id和锁获取计数器,其初始值分别为NO_CURRENT_THREAD和0。
一个已经获得了锁的线程每次调用acquire函数都会将refcount计数加1,每次调用release函数会将refcount计数减1,只有当refcount计数减到0了,就表示获取锁的线程已经可以完全释放锁了,这时才会将currentThread中记录的线程ID重置为NO_CURRENT_THREAD。
// currentThread保存当前线程访问KafkaConsumer的threadId,防止多线程并发访问
private final AtomicLong currentThread = new AtomicLong(NO_CURRENT_THREAD);
// refcount用于允许已获得currentThread的线程进行可重入访问
private final AtomicInteger refcount = new AtomicInteger(0);
// 获得一个保证consumer是未关闭状态的轻量锁
private void acquireAndEnsureOpen() {
acquire();
// 如果consumer关闭了,则释放锁
if (this.closed) {
release();
throw new IllegalStateException("This consumer has already been closed.");
}
}
// 并发检测,采用非阻塞的方式来获取轻量锁,如果锁不可用,则直接抛出异常,不允许并发。
private void acquire() {
long threadId = Thread.currentThread().getId();
// 当currentThread中设置的线程id和当前线程id不同,同时currentThread当前值不是NO_CURRENT_THREAD,表示存在并发。
// 当不存在并发时,且不是以允许访问线程重入时,会将当前线程id设置到currentThread中。
if (threadId != currentThread.get() && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId))
throw new ConcurrentModificationException("KafkaConsumer is not safe for multi-threaded access");
refcount.incrementAndGet();
}
// 释放锁并且refcount减1,只有当refcount减到0时,设置currentThread为NO_CURRENT_THREAD,
private void release() {
if (refcount.decrementAndGet() == 0)
currentThread.set(NO_CURRENT_THREAD);
}