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测试结果的因素:

  1. 内核版本,相较于旧的内核比如4.19,新的内核在唤醒流程上增加了很多处理逻辑,因此新的内核在唤醒速度上不及旧的内核;
  2. 当wakee所在的cpu跟waker不一致时会发送ipi中断,因此IPI中断的投递效率也是影响唤醒速度一个环节;
  3. 给wakee选择的cpu如果处于idle状态(常常如此,而且这种情形效率较高),也就是此时该cpu的cstate可能是大于0的,那么它能恢复到C0状态的速度也是影响cpu唤醒速度的关键。假如cpu处于比较深度的睡眠,恢复到C0耗时就会比较长。cpuidle策略也会对cstate的状态,即唤醒逻辑产生影响;
  4. 当测试多个worker线程的时候,cpu数量会影响到wakee cpu的选择,cpu越多可选择的数量越大,越不容易发生wakee cpu重叠的问题,如果worker线程超过cpu数量,唤醒速度急速下降;
  5. 在开启超线程的情况下,如果wakee cpu跟message cpu共享同一个物理core,message线程的性能会发生下降,因为唤醒是依次进行的,因此后面被唤醒的线程会受到影响。这种情况通常在worker线程数达到物理core数量时发生,而且因为worker线程在加入message唤醒队列时是加入链表头的,因此唤醒顺序间隔发生反转,导致message线程性能影响是稳定发生的;
  6. 对于多socket多numa的机器,绑定socket,numa会提升性能;
  7. 被唤醒者选择唤醒者所在的cpu唤醒速度最高,前提是唤醒者在唤醒动作之后会放弃cpu,其次被唤醒者跟唤醒者共享L3cache也会有性能提升;

 

posted on 2024-11-06 12:10  半山随笔  阅读(71)  评论(0编辑  收藏  举报

导航