前言
本文为笔者个人阅读Apache Impala源码时的笔记,仅代表我个人对代码的理解,个人水平有限,文章可能存在理解错误、遗漏或者过时之处。如果有任何错误或者有更好的见解,欢迎指正。
正文
在前置文章Impala3.4源码阅读笔记(七)解析ScanNode(上)中,我们分析了ScanNode
的继承与派生关系,以Kudu扫描结点为例子介绍了多实例版本ScanNode
的结构定义和工作流程。本文将续接上文,继续介绍多线程版本ScanNode
的实现和原理。建议阅读完上文后再阅读本文。
多线程版本的Kudu扫描结点KuduScanNode
上文说到KuduScanNodeBase
有两个派生类,分别为KuduScanNode
和KuduScanNodeMT
,其中KuduScanNode
是更为常用的版本,其内部实现了多线程的扫描逻辑,其工作时会根据一定的规则自动孵化新的扫描线程实现并发扫描。首先看其定义:
class KuduScanNode : public KuduScanNodeBase {
public:
KuduScanNode(ObjectPool* pool, const ScanPlanNode& pnode, const DescriptorTbl& descs);
~KuduScanNode();
virtual Status Prepare(RuntimeState* state) override;
virtual Status Open(RuntimeState* state) override;
virtual Status GetNext(RuntimeState* state, RowBatch* row_batch, bool* eos) override;
virtual void Close(RuntimeState* state) override;
// NON_TASK_BASED_SYNC指的是会产生多个工作线程的执行模型,另外还有HdfsScanNode等属于此类型。
virtual ExecutionModel getExecutionModel() const override { return NON_TASK_BASED_SYNC; }
private:
friend class KuduScanner;
// ScannerThreadState是实现多个扫描线程管理类,其封装了与具体扫描逻辑无关的扫描线程管理供各类扫描结点使用。
ScannerThreadState thread_state_;
/// 保证各扫描线程对某些成员的正确访问的互斥锁。
std::mutex lock_;
/// 扫描结点的当前状态,如果发生任何问题,例如扫描发生错误,则设置为非OK,由lock_保证互斥访问。
Status status_;
/// 在扫描完成时设置为true(可能是因为所有的scan token都已经处理,或达到了limit限制,或者发生了错误)。
AtomicBool done_;
/// 下文线程回调函数ThreadAvailableCb被添加到线程资源管理器时返回的id。
/// 用于在扫描结点Close之前从线程资源管理器移除回调函数。
int thread_avail_cb_id_;
/// 计算单个Kudu扫描线程的预计内存消耗。
int64_t EstimateScannerThreadMemConsumption();
/// 当有资源池中现有线程结束时可能会被调用的回调函数,这将在满足一定条件的情况下尝试孵化一个新的扫描线程。
void ThreadAvailableCb(ThreadResourcePool* pool);
/// 扫描线程的主函数,它会创建一个KuduScanner,并处理传入的初始扫描令牌initial_token,
/// 然后继续通过GetNextScanToken()获取待处理的令牌,直到没有剩余扫描令牌、发生错误或达到limit限制为止。
/// 调用前必须先为新线程向ThreadResourceMgr申请一个线程令牌,此函数返回之前会释放令牌。
/// 所谓线程令牌可以理解为一个运行线程的许可证,ThreadResourceMgr许可之后才可以创建新线程。
/// 如果是该结点的首个扫描线程,first_thread需设置为true,且则其需要Acquire方式申请线程令牌,
/// Acquire方式即ThreadResourceMgr必须通过申请,因为扫描结点至少需要一个扫描线程。
/// 首个线程将持续运行,直到'done_'为true或遇到错误,而其他扫描线程可能会提前终止,
/// 因为非首个线程使用TryAcquire方式申请令牌,线程数量超过了线程资源池的限制时,TryAcquire的令牌会被收回。
void RunScannerThread(
bool first_thread, const std::string& name, const std::string* initial_token);
/// 处理单个扫描令牌,使用scanner扫描并获取RowBatch,并将其放入行批队列中,
/// 直到扫描令牌eos、发生错误或达到limit限制为止。
Status ProcessScanToken(KuduScanner* scanner, const std::string& scan_token);
/// 将done_设置为true并调用扫描线程管理的停止方法。必须在拥有lock_的情况下调用。
void SetDoneInternal() {
if (done_.Load()) return;
done_.Store(true);
thread_state_.Shutdown();
}
/// 获取lock_并调用SetDoneInternal()。
void SetDone() {
unique_lock<mutex> l(lock_);
SetDoneInternal();
}
};
可以发现KuduScanNode
的定义相较于KuduScanNodeMT
要复杂得多,我们首先看其几个public方法的实现:
Status KuduScanNode::Prepare(RuntimeState* state) {
// 调用父类的KuduScanNodeBase::Prepare和扫描线程管理ScannerThreadState的Prepare,
// ScannerThreadState::Prepare需要的内存预估值由EstimateScannerThreadMemConsumption计算。
RETURN_IF_ERROR(KuduScanNodeBase::Prepare(state));
thread_state_.Prepare(this, EstimateScannerThreadMemConsumption());
return Status::OK();
}
Status KuduScanNode::Open(RuntimeState* state) {
SCOPED_TIMER(runtime_profile_->total_time_counter());
// 添加生命周期事件,ScopedOpenEventAdder创建时在profile中添加"Open Started"事件,
// 析构时添加"Open Finished"事件,事件包含了对应的时间点和耗时情况。
ScopedOpenEventAdder ea(this);
// 调用父类的KuduScanNodeBase::Open和扫描线程管理ScannerThreadState的Open,
// ScannerThreadState::Open需要的行批队列长度由配置kudu_max_row_batches给出。
RETURN_IF_ERROR(KuduScanNodeBase::Open(state));
thread_state_.Open(this, FLAGS_kudu_max_row_batches);
// 在本查询的线程资源池中注册线程回调函数,所有注册后的回调函数会在有线程令牌释放时被轮询调用。
// 本查询的其他结点也可能注册回调函数,这样一个线程资源池就可以控制一个查询的所有线程的数量。
thread_avail_cb_id_ = state->resource_pool()->AddThreadAvailableCb(
bind<void>(mem_fn(&KuduScanNode::ThreadAvailableCb), this, _1));
// 主动调用一次ThreadAvailableCb来创建首个扫描线程。
ThreadAvailableCb(state->resource_pool());
return Status::OK();
}
Status KuduScanNode::GetNext(RuntimeState* state, RowBatch* row_batch, bool* eos) {
SCOPED_TIMER(runtime_profile_->total_time_counter());
// 添加生命周期事件,ScopedOpenEventAdder创建时在profile中添加"First Batch Requested"事件,
// 析构时添加"First Batch Returned"事件(eos为true时则是"Last Batch Returned"),
// 事件包含了对应的时间点和耗时情况。
ScopedGetNextEventAdder ea(this, eos);
RETURN_IF_ERROR(ExecDebugAction(TExecNodePhase::GETNEXT, state));
RETURN_IF_CANCELLED(state);
RETURN_IF_ERROR(QueryMaintenance(state));
// 如果没有待处理的扫描令牌或者达到了limit限制,则设置eos并直接返回。
// ReachedLimitShared()是线程安全版本的ReachedLimit(),可以检查当前结点返回行数是否达到limit。
if (NumScanTokens() == 0 || ReachedLimitShared()) {
*eos = true;
return Status::OK();
}
*eos = false;
// 从行批队列中获取下一个行批,如果队列为空则阻塞。如果队列已经关闭,则返回nullptr。
// 扫描线程会扫描并物化行批放入行批队列。
unique_ptr<RowBatch> materialized_batch = thread_state_.batch_queue()->GetBatch();
if (materialized_batch != NULL) {
// 将materialized_batch中的数据所有权转移到row_batch。
row_batch->AcquireState(materialized_batch.get());
// 调用ExecNode::CheckLimitAndTruncateRowBatchIfNeeded的线程安全版本,
// 进行limit限制检查、更新计数器和截断多余数据行,达到limit限制时调用SetDone()。
if (CheckLimitAndTruncateRowBatchIfNeededShared(row_batch, eos)) {
SetDone();
}
COUNTER_SET(rows_returned_counter_, rows_returned_shared());
materialized_batch.reset();
} else {
*eos = true;
}
// 需要上锁,因为istatus_在多个线程中共享更新,因此任意线程出错都可以感知。
unique_lock<mutex> l(lock_);
return status_;
}
void KuduScanNode::Close(RuntimeState* state) {
if (is_closed()) return;
SCOPED_TIMER(runtime_profile_->total_time_counter());
// 移除Open中注册的回调函数。
if (thread_avail_cb_id_ != -1) {
state->resource_pool()->RemoveThreadAvailableCb(thread_avail_cb_id_);
}
// 设置done_为true,令所有的扫描线程退出。
SetDone();
// 调用扫描线程管理ScannerThreadState的Close和父类的KuduScanNodeBase::Close。
thread_state_.Close(this);
KuduScanNodeBase::Close(state);
}
介绍完了KuduScanNode
的几个public方法,我们继续分析其关键的几个private方法,首先是用于孵化新线程的回调函数ThreadAvailableCb
:
void KuduScanNode::ThreadAvailableCb(ThreadResourcePool* pool) {
// ScannerMemLimiter是用于跟踪扫描线程数量和使用内存并限制内存消耗的类。
ScannerMemLimiter* mem_limiter = runtime_state_->query_state()->scanner_mem_limiter();
// 不断循环以尽可能多地创建扫描线程实现高并发,直到某些情况出现不允许继续创建。
while (true) {
unique_lock<mutex> lock(lock_);
// 情况1:已完成或没有更多扫描令牌时,不再创建新线程。
if (done_.Load() || !HasScanToken()) break;
// GetNumActive()返回当前活动的线程数,为0说明尚未创建任何线程,即将创建首个线程。
bool first_thread = thread_state_.GetNumActive() == 0;
// 对于非首个线程,检查情况2和3。
if (!first_thread) {
// 情况2:行批队列已满,说明扫描线程数量足够,不构成查询瓶颈,不再创建新线程。
if (thread_state_.batch_queue()->IsFull()) break;
// 情况3:为扫描器线程预留内存失败,没有足够的预留内存时,不再创建新线程。
if (!mem_limiter->ClaimMemoryForScannerThread(
this, EstimateScannerThreadMemConsumption())) {
COUNTER_ADD(thread_state_.scanner_thread_mem_unavailable_counter(), 1);
break;
}
}
if (first_thread) {
// 对于首个线程使用AcquireThreadToken方式来申请令牌,即使会超出资源池容量限制也总是会申请成功,
// 因为结点至少需要一个扫描线程才能工作。
pool->AcquireThreadToken();
// 情况4:活动线程数量已经达到了配置max_num_scanner_threads的限制,不再创建新线程。
} else if (thread_state_.GetNumActive() >= thread_state_.max_num_scanner_threads()
// 对于非首个线程使用TryAcquireThreadToken方式来申请令牌,受到资源池容量限制可能会申请失败。
// 情况5:TryAcquireThreadToken申请令牌失败,不再创建新线程。
|| !pool->TryAcquireThreadToken()) {
// 由于上文调用了ClaimMemoryForScannerThread进行内存预留,此处需要释放。
mem_limiter->ReleaseMemoryForScannerThread(
this, EstimateScannerThreadMemConsumption());
break;
}
// 通过所有检查,开始创建新扫描线程,首先定义线程名字。
string name = Substitute(
"kudu-scanner-thread (finst:$0, plan-node-id:$1, thread-idx:$2)",
PrintId(runtime_state_->fragment_instance_id()), id(),
thread_state_.GetNumStarted());
// 为新线程先保留第一个扫描令牌,这样其他工作中的线程就不会再获取到。
const string* token = GetNextScanToken();
// 将RunScannerThread和相关参数包装为lambda函数。
auto fn = [this, first_thread, token, name]() {
this->RunScannerThread(first_thread, name, token);
};
std::unique_ptr<Thread> t;
// 调用Thread::Create()创建线程。
Status status =
Thread::Create(FragmentInstanceState::FINST_THREAD_GROUP_NAME, name, fn, &t, true);
if (!status.ok()) {
// 创建线程失败,需要释放线程令牌,ReleaseThreadToken第一个参数表示是否为Acquire方式申请的令牌,
// 第二个参数为skip_callbacks,默认为false,表示是否跳过调用回调函数。
// 此处跳过运行回调函数有两个原因,首先,它防止了该函数和ReleaseThreadToken()之间的相互递归,
// 其次,Thread::Create()失败了则很可能在以后的回调中继续失败。
pool->ReleaseThreadToken(first_thread, true);
if (!first_thread) {
// 非首个线程还调用了ClaimMemoryForScannerThread进行内存预留,此处需要释放。
mem_limiter->ReleaseMemoryForScannerThread(
this, EstimateScannerThreadMemConsumption());
}
DCHECK(status_.ok());
// 将失败原因传递到status_,此时获取了锁lock_可以直接执行SetDoneInternal。
status_ = status;
SetDoneInternal();
break;
}
// 线程创建成功,添加到扫描线程管理中。
thread_state_.AddThread(move(t));
}
}
最后我们分析一下KuduScanNode
中扫描线程到底是如何运行的,见函数RunScannerThread
和ProcessScanToken
:
void KuduScanNode::RunScannerThread(
bool first_thread, const string& name, const string* initial_token) {
DCHECK(initial_token != nullptr);
// SCOPED_THREAD_COUNTER_MEASUREMENT用于统计线程CPU时间和利用率,用法和计时器SCOPED_TIMER类似。
SCOPED_THREAD_COUNTER_MEASUREMENT(thread_state_.thread_counters());
SCOPED_THREAD_COUNTER_MEASUREMENT(runtime_state_->total_thread_statistics());
// 创建一个KuduScanner用于连接Kudu、物化行批。
KuduScanner scanner(this, runtime_state_);
// 传入的initial_token作为第一个处理的扫描令牌,调用KuduScanner::Open开启扫描器。
const string* scan_token = initial_token;
Status status = scanner.Open();
if (status.ok()) {
// 线程主循环,直到扫描完成或者没有需要处理的扫描令牌。
while (!done_.Load() && scan_token != nullptr) {
// 调用ProcessScanToken处理一个扫描令牌,若有错误则退出循环。
status = ProcessScanToken(&scanner, *scan_token);
if (!status.ok()) break;
// 正如上文所述,对于非首个线程,其线程令牌是通过TryAcquireThreadToken申请的,
// 因此需要经常性地检查线程令牌是否已经超过了资源池容量,若超过则需要主动退出释放令牌。
// 当然,有比较罕见的情况,同时有多个线程检查令牌后退出,但即使如此新线程也会快速补充,
// 因为当ThreadAvailableCb()再次被调用时就有空余的令牌可以再次申请来孵化线程。
if (!first_thread && runtime_state_->resource_pool()->optional_exceeded()) break;
// 扫描未完成时,继续尝试获取下一个扫描令牌在下个循环处理,无剩余扫描令牌时获取到nullptr退出循环。
if (!done_.Load()) {
unique_lock<mutex> l(lock_);
scan_token = GetNextScanToken();
} else {
scan_token = nullptr;
}
}
}
// 主循环退出后,扫描器不再使用,可以关闭。
scanner.Close();
{
// lock_在调用ThreadResourceMgr::ReleaseThreadToken()之前需要释放,
// 因为后者会调用的ThreadAvailableCb()会尝试获取相同的锁。
unique_lock<mutex> l(lock_);
// 检查当前线程状态是否有错误,有则同步到status_并设置完成标识。
if (!status.ok() && status_.ok()) {
status_ = status;
SetDoneInternal();
}
// 线程即将退出,令活跃线程数减一,若是最后一个活跃线程说明扫描完成,设置完成标识。
if (thread_state_.DecrementNumActive()) SetDoneInternal();
}
VLOG_RPC << "Thread done: " << name;
// 非首个线程还调用了ClaimMemoryForScannerThread进行内存预留,此处需要释放。
if (!first_thread) {
ScannerMemLimiter* mem_limiter = runtime_state_->query_state()->scanner_mem_limiter();
mem_limiter->ReleaseMemoryForScannerThread(
this, EstimateScannerThreadMemConsumption());
}
// 调用ReleaseThreadToken释放线程令牌,ReleaseThreadToken中InvokeCallbacks会调用注册的回调函数。
runtime_state_->resource_pool()->ReleaseThreadToken(first_thread);
}
Status KuduScanNode::ProcessScanToken(KuduScanner* scanner, const string& scan_token) {
bool eos;
// 调用KuduScanner::OpenNextScanToken进行开启传入的扫描令牌。
RETURN_IF_ERROR(scanner->OpenNextScanToken(scan_token, &eos));
// 如果该扫描令牌没有需要扫描的行,则eos会被置为true,可以直接返回。
if (eos) return Status::OK();
// 处理扫描令牌的主循环,直到令牌eos或者扫描完成。
while (!eos && !done_.Load()) {
// 创建一个空的行批RowBatch准备填充数据。
unique_ptr<RowBatch> row_batch = std::make_unique<RowBatch>(row_desc(),
runtime_state_->batch_size(), mem_tracker());
// 调用扫描器的GetNext方法填充RowBatch。
RETURN_IF_ERROR(scanner->GetNext(row_batch.get(), &eos));
while (!done_.Load()) {
// 让KuduScanner向Kudu服务发送Ping以保持活动状态。
scanner->KeepKuduScannerAlive();
// 尝试将填充完数据的行批放入行批队列,超时1s,循环直到放入成功或扫描已完成。
if (thread_state_.EnqueueBatchWithTimeout(&row_batch, 1000000)) {
break;
}
// 确保如果BlockingPutWithTimeout()超时,我们仍然拥有该RowBatch(没有被转移)。
DCHECK(row_batch != nullptr);
}
}
// 扫描令牌eos,处理完毕,更新计数器并返回。
if (eos) scan_ranges_complete_counter_->Add(1);
return Status::OK();
}
至此KuduScanNode
就分析完毕了,可以发现因为需要实现自适应的多线程扫描逻辑,其比KuduScanNodeMT
要复杂一些,实际上还有部分通用的多线程逻辑代码被抽象出来封装成了ScannerThreadState
供所有多线程扫描结点使用,以减少代码重复量并降低开发难度,接下来我们就再看看ScannerThreadState
是怎么实现的。
实现扫描线程管理的ScannerThreadState
ScannerThreadState
实际上并不复杂,其主要提供了功能包括三项:简单的线程管理、线程安全的行批队列和性能统计,我们首先看其定义:
class ScannerThreadState {
public:
/// 执行准备操作,包括初始化大部分计数器、计时器和内存追踪器。
void Prepare(ScanNode* parent, int64_t estimated_per_thread_mem);
/// 执行开启操作,包括计算行批队列大小,创建行批队列和并发度监控计数器。
void Open(ScanNode* parent, int64_t max_row_batches_override);
/// 关闭行批队列,此时所有因为队列操作(get/put)而阻塞的线程都会被唤醒,
/// 因队列满put而阻塞的行批和调用该函数之后再加入的行批都会加入一个暂存队列,
/// 因为先前的行批可能应用了暂存队列行批的缓冲区,所以直到Close之前不能销毁它们。
void Shutdown();
/// 执行关闭操作,等待所有扫描线程退出完成并清理行批队列。
void Close(ScanNode* parent);
/// 向线程组添加一个新的扫描器线程,非线程安全的。
void AddThread(std::unique_ptr<Thread> thread);
/// 获取活动的扫描线程的数量,线程安全的。
int32_t GetNumActive() const { return num_active_.Load(); }
/// 获取至今启动了扫描线程的数量,线程安全的。
int32_t GetNumStarted() const { return num_threads_started_->value(); }
/// 从正在退出的扫描线程里调用,以减少活动扫描线程的数量。
/// 如果这是最后一个退出的线程,则返回true,线程安全的。
bool DecrementNumActive();
/// 扫描线程调用的,向行批队列添加一个行批,如果行批队列已满,此函数将阻塞,线程安全的。
void EnqueueBatch(std::unique_ptr<RowBatch> row_batch);
/// 扫描线程调用的,向行批队列添加一个行批,如果行批队列已满,此函数将阻塞直到超时timeout_micros,
/// 线程安全的。添加成功返回true并获取row_batch所有权,否则返回false,不获取所有权。
bool EnqueueBatchWithTimeout(std::unique_ptr<RowBatch>* row_batch,
int64_t timeout_micros);
BlockingRowBatchQueue* batch_queue() { return batch_queue_.get(); }
RuntimeProfile::ThreadCounters* thread_counters() const { return thread_counters_; }
int max_num_scanner_threads() const { return max_num_scanner_threads_; }
int64_t estimated_per_thread_mem() const { return estimated_per_thread_mem_; }
RuntimeProfile::Counter* scanner_thread_mem_unavailable_counter() const {
return scanner_thread_mem_unavailable_counter_;
}
private:
/// 保存所有扫描线程的线程组,用于Close时等待所有线程退出。
ThreadGroup scanner_threads_;
/// 扫描线程的最大数目。如果设置了Query Option,则设置为'NUM_SCANNER_THREADS'。
/// 否则,它被设置为cpu核数。在Open()中计算和设置。
int max_num_scanner_threads_ = 0;
/// 估计每个额外扫描线程将消耗的内存量,用于决定是否有足够的内存来创建一个新的扫描线程。
int64_t estimated_per_thread_mem_ = 0;
/// 追踪行批队列内存使用的内存追踪器。
MemTracker* row_batches_mem_tracker_ = nullptr;
/// 行批队列。放入的行批由扫描线程异步生成,供在扫描节点上调用GetNext()的主片段线程获取。
boost::scoped_ptr<BlockingRowBatchQueue> batch_queue_;
/// 当前正在运行的活动扫描线程数量。
AtomicInt32 num_active_{0};
/// 各种计数器和计时器。
RuntimeProfile::ThreadCounters* thread_counters_ = nullptr;
RuntimeProfile::Counter* average_concurrency_ = nullptr;
RuntimeProfile::HighWaterMarkCounter* peak_concurrency_ = nullptr;
RuntimeProfile::Counter* num_threads_started_ = nullptr;
RuntimeProfile::Counter* row_batches_enqueued_ = nullptr;
RuntimeProfile::Counter* row_batch_bytes_enqueued_ = nullptr;
RuntimeProfile::Counter* row_batches_get_timer_ = nullptr;
RuntimeProfile::Counter* row_batches_put_timer_ = nullptr;
RuntimeProfile::Counter* row_batches_peak_mem_consumption_ = nullptr;
RuntimeProfile::Counter* scanner_thread_mem_unavailable_counter_ = nullptr;
};
至此ScannerThreadState
也分析完毕,KuduScanNode
里值得进一步分析的内容就剩实现扫描逻辑的Kudu扫描器KuduScanner
了。KuduScanner
还是有一定复杂度的,受限于篇幅,我们再下一篇文章中再进行分析。