schbench源码分析
schbench是meta开发的linux调度器benchmark工具,用来测试线程wakeup到占有cpu之间的延迟。
如何使用schbench?
schbench -t 2 -m 1
在schbench中有两个重要的概念,woker线程和message线程。message线程由主线程创建,worker线程由message线程创建。这就是schbench的线程模型。woker和message线程的数量由上述命令中的-t和-m指定。默认运行30s后输出结果。
Latency percentiles (usec) 50.0000th: 7 75.0000th: 7 90.0000th: 7 95.0000th: 7 *99.0000th: 9 99.5000th: 11 99.9000th: 16 min=0, max=16
schbench会做多次试验,结果排序后得到百分位段的延迟数据,一般会将99%位段的值作为参考结果。
下面通过源码分析来了解其原理。
schbench的main函数首先解析传入的参数。我们重点看worker线程数和message线程数。参数解析完成后会创建message线程
/* start our message threads, each one starts its own workers */ for (i = 0; i < message_threads; i++) { pthread_t tid; ret = pthread_create(&tid, NULL, message_thread, message_threads_mem + i); ... message_threads_mem[i].tid = tid; }
message_threads即是解析得到的message线程数。message_thread是线程执行的函数,message_threads_mem是所有线程入参数组的首地址。
message_thread的主要工作是创建worker线程之后与worker线程互动,互相唤醒对方。
void *message_thread(void *arg) { ... for (i = 0; i < worker_threads; i++) { pthread_t tid; worker_threads_mem[i].msg_thread = td; ret = pthread_create(&tid, NULL, worker_thread, worker_threads_mem + i); if (ret) { fprintf(stderr, "error %d from pthread_create\n", ret); exit(1); } worker_threads_mem[i].tid = tid; } ... run_msg_thread(td); ... }
worker线程的创建与message线程类似。创建完worker线程之后message_thread调用run_msg_thread,该函数是一个while循环
static void run_msg_thread(struct thread_data *td) { unsigned int seed = pthread_self(); int max_jitter = sleeptime / 4; int jitter = 0; while (1) { td->futex = FUTEX_BLOCKED; xlist_wake_all(td); if (stopping) { xlist_wake_all(td); break; } fwait(&td->futex, NULL); //调用futex在&td->futex上等待 /* * messages shouldn't be instant, sleep a little to make them * wait */ if (!pipe_test && sleeptime) { jitter = rand_r(&seed) % max_jitter; usleep(sleeptime + jitter); } } }
该循环的工作是唤醒worker->wait→sleep。从这个函数上会有一些疑问:为什么message线程在被唤醒之后没有马上执行唤醒动作而是先sleep一段时间?这个疑问需要结合worker线程一起看。
void *worker_thread(void *arg) { ... while(1) { ... req = msg_and_wait(td); } ...
worker thread在while循环中调用msg_and_wait。
static struct request *msg_and_wait(struct thread_data *td) { ... td->futex = FUTEX_BLOCKED; gettimeofday(&td->wake_time, NULL); ... xlist_add(td->msg_thread, td); ... fpost(&td->msg_thread->futex); //唤醒在&td->msg_thread->futex上等待的线程 ... if (!requests_per_sec) { gettimeofday(&now, NULL); delta = tvdelta(&td->wake_time, &now); if (delta > 0) add_lat(&td->stats, delta); } return NULL; }
msg_and_wait函数看起来做的事情是:
获取当前时间->将自己加入到message线程唤醒队列中->唤醒message线程→获取时间→计算时间差
联系message线程的流程,唤醒时间的计算似乎是:
worker获取时间t0->worker将自身加入到message线程唤醒队列->worker唤醒message线程->message线程被唤醒然后sleep一段时间→message线程醒来后唤醒worker线程->worker线程获取时间t1->worker线程计算时间差t1-t0.
如果将t1-t0作为worker线程的唤醒时间显然是错误的,这跟我们在测试的时候得到的以us计的结果差距很大。这其中的关键要在run_msg_thread调用的xlist_wake_all中找。
static void xlist_wake_all(struct thread_data *td) { ... list = xlist_splice(td); gettimeofday(&now, NULL); while (list) { next = list->next; list->next = NULL; if (pipe_test) { ... } else { memcpy(&list->wake_time, &now, sizeof(now)); } fpost(&list->futex); list = next; } }
该函数首先获取时间然后在while循环中一个个唤醒worker线程,而在唤醒之前会将得到的时间替换调worker线程所记录的时间!这就是为什么上面分析与结果大相径庭的原因,时间被篡改了。这一改流程就可以由下图表示出来。
message线程在唤醒worker之前会获取时间,以此作为起始时间,等到worker被唤醒后记录结束时间,两者个差作为worker的唤醒时间。这就合理了。我们也能够明白message需要在被唤醒后sleep的原因,它要保证在唤醒动作发出时worker线程处于wait状态。需要注意的一点是,当worker线程数增多时测量得到的时间会逐步增大,但是这并非是worker被唤醒的时间变大了,而是message线程在唤醒多个worker时首先获取时间,依次唤醒,所以在后面唤醒的worker起始时间早于真实唤醒作动发生时间。
深入分析唤醒流程
由上面的分析可知,message线程首先会gettime,在调用fpost唤醒worker之前会先做一次memory copy更新wakee的时间。要访问的数据结构是由main线程分配的,如果main线程与message线程所在的cpu共享L3cache会提升内存访问的速度也因此会提升测试成绩。fpost会调用futex系统调用唤醒waker,这一操作主要在内核中进行。下面简要分析。
在内核中的流程可以分为两个阶段:在waker中执行部分和在wakee中执行部分,通常两者不在一个cpu上,但也有可能在一个cpu上。先看一下waker部分。
首先是futex_wake会找到要唤醒的队列,try_to_wake_up会为wakee找到合适的cpu,ttwu_queue_wakelist将wakee cpu需要执行的任务加入其相应数据结构中,发送IPI通知wakee cpu。这里最为关键的部分是为将要唤醒的任务选择合适的cpu。其实IPI也可以不发送,比如选择的cpu为本cpu,或者在比较旧的内核中,如4.19,对于wakee和waker共享L3cache的情形是不发送IPI中断的,一般不发送IPI中断会提升唤醒速度。
wakee执行部分。
针对wakee与waker不是同一cpu的情形,典型的wakee可能正处在idle状态
static void do_idle(void) { ... while (!need_resched()) { ... if (cpu_idle_force_poll || tick_check_broadcast_expired()) { ... } else { cpuidle_idle_call(); } arch_cpu_idle_exit(); } ... flush_smp_call_function_queue(); schedule_idle(); ... }
当IPI到来的时候cpu在cpuidle_idle_call中,已经处于c-state大于0的状态了,具体进入那个c-state跟bios配置,cpuidle governor的选择都有关系。这里会影响唤醒速度的地方是c-state越大返回都C0的耗时越久。在中断使能之后cpu会处理中断,IPI中断处理函数会调用flush_smp_call_function_queue将需要唤醒的task加入到自身的rq中,ftrace如下:
cpuidle_idle_call返回后,idle进程的task_info结构已经被打上TIF_NEED_RESCHED,while循环退出。
如果IPI中断已经处理了自身的call_single_queue中的事件,while循环外的flush_smp_call_function_queue就无事可做。schedule_idle会主动进行一次调度,如果只有刚刚enqueue的worker线程,该task即会被选中成为next进程。
接下来worker会从之前调用的futex中返回到用户态。第一件事就是gettime,以此作为唤醒的终结时间。
对于多个worker线程的情形,除了第一个worker,其他的worker线程的起始唤醒时间的延迟会逐步增加,每次增加的延迟最大的来源是futex系统调用,大约会消耗3us左右。导致总的测试结果增大。
影响schbench测试结果的因素:
- 内核版本,相较于旧的内核比如4.19,新的内核在唤醒流程上增加了很多处理逻辑,因此新的内核在唤醒速度上不及旧的内核;
- 当wakee所在的cpu跟waker不一致时会发送ipi中断,因此IPI中断的投递效率也是影响唤醒速度一个环节;
- 给wakee选择的cpu如果处于idle状态(常常如此,而且这种情形效率较高),也就是此时该cpu的cstate可能是大于0的,那么它能恢复到C0状态的速度也是影响cpu唤醒速度的关键。假如cpu处于比较深度的睡眠,恢复到C0耗时就会比较长。cpuidle策略也会对cstate的状态,即唤醒逻辑产生影响;
- 当测试多个worker线程的时候,cpu数量会影响到wakee cpu的选择,cpu越多可选择的数量越大,越不容易发生wakee cpu重叠的问题,如果worker线程超过cpu数量,唤醒速度急速下降;
- 在开启超线程的情况下,如果wakee cpu跟message cpu共享同一个物理core,message线程的性能会发生下降,因为唤醒是依次进行的,因此后面被唤醒的线程会受到影响。这种情况通常在worker线程数达到物理core数量时发生,而且因为worker线程在加入message唤醒队列时是加入链表头的,因此唤醒顺序间隔发生反转,导致message线程性能影响是稳定发生的;
- 对于多socket多numa的机器,绑定socket,numa会提升性能;
- 被唤醒者选择唤醒者所在的cpu唤醒速度最高,前提是唤醒者在唤醒动作之后会放弃cpu,其次被唤醒者跟唤醒者共享L3cache也会有性能提升;