聊聊多线程程序的load balance
说起load balance,一般比较容易想到的是大型服务在多个replica之间的load balance、和kernal的load balance。前者一般只是在流量入口做一下流量分配,逻辑相对简单;而后者则比较复杂,需要不断发现正在运行的各个进程之间的imbalance,然后通过将进程在CPU之间进行迁移,使得各个CPU都被充分利用起来。
而本文想要讨论的load balance有别于以上两种,它是多线程(多进程)server程序内部,各个worker线程(进程)之间的load balance。
考虑一种常用的server模型:一个receiver线程负责接收请求,后面有一个线程池装了一堆worker线程,收到的请求被分派给这些worker进行处理。receiver与worker之间通过pthread_cond+request_queue来进行通信。一般的做法是:receiver将收到的请求放入queue,然后signal一下cond,就OK了。具体哪个worker会被唤醒,那是kernel的事情(实际上kernel会遵循先来后到原则,唤醒先进入等待的进程,参阅《linux futex浅析》)。通常情况下这样做就足够了,receiver唤醒worker不需要涉及load balance的逻辑。但是有时候我们还是可以做一些load balance的工作,来提高server的性能。
kernel load balance概述
由于这里的load balance跟kernel的load balance息息相关,所以我们有必要先看看kernel的load balance都做了些什么。详细的内容请参阅《linux内核SMP负载均衡浅析》,这里只做一些简要的概括。
说白了,kernel的load balance就做一件事情: 让系统中RUNNING状态的进程尽可能的被分摊,在每一个调度域上看都是balance的 。怎么理解呢?现在CPU的结构一般有:物理CPU、core、超线程、这么几个层次。”在每一个调度域上看都balance”可以理解为在每一个层次上都balance:每个物理CPU上的总load相当、每个core上的总load相当、每个超线程上的load也相当。
我们在系统中看到的”CPU”都是最底层的超线程这个层次,我们可能会直观的认为把RUNNING状态的进程分摊到每一个”CPU”上就行了,但是实际上kernel的load balance还有更高的要求。假设我们的机器有2个物理CPU、每个物理CPU有2个core、每个core有2个超线程,共8个”CPU”。如果现在有8个RUNNING状态的进程(假设优先级都相同),每个”CPU”各分摊一个进程,那么自然就是balance的。但是如果现在只有4个RUNNING状态的进程(假设优先级都相同),真正的balance并不仅仅是每个进程各自落到一个”CPU”上就行了,而是进一步要求每个物理CPU上跑两个进程、每个core上跑一个进程。
为什么要有这样的强约束呢?因为尽管各个”CPU”逻辑上是独立的(不存在主从关系之类),但它们并非孤立存在。相同物理CPU下的”CPU”会共享cache、相同core下的”CPU”会共享计算资源(所谓的超线程也就是一套流水线跑两个线程)。而共享也就意味着争抢。所以,在RUNNING状态的进程并非正好均摊给每一个”CPU”的情况下,需要考虑更高层次的CPU是否被均摊,以避免cache和CPU流水线的争抢(当然,除了性能,这也体现了kernel的公平性)。
最后再多提一点,kernel的load balance是异步的。为避免占用过多资源,kernel肯定不可能实时监控各个”CPU”的情况,然后面对变化实时的做出反应(当然,实时进程除外,但这不在我们讨论范围内)。
server的load balance考虑
有了kernel的load balance作为铺垫,看看我们server上的receiver线程能做些什么吧。
首先是worker线程的数量问题。如果worker数量过多会发生什么情况?还是假设我们的机器有上述的8个”CPU”,假设我们开了80个worker,再假设这80个线程被平均分派到每一个”CPU”上,等待处理任务。当一堆请求陆续到来的时候,由于我们的receiver没有任何load balance的策略,被唤醒的worker出现在哪个”CPU”上可以说是随机的。你想想,”同时”到来的8个请求正好落到8个不同”CPU”上的概率是多少?是:(70*60*50*40*30*20*10)/(79*78*77*76*75*74*73)=0.34%。也就是说几乎肯定会出现某些”CPU”要处理多个请求、某些”CPU”却闲着没事干的情况,系统的性能可想而知。而等到后知后觉的kernel load balance将这些请求balance到每一个”CPU”上时,可能请求已经处理得差不多了,等到下一批请求到来时,load又还是凌乱的。因为刚刚已经balance好的那些worker线程又被放回到了cond等待队列的尾部,而优先响应新请求的则是那些位于队列头部的未曾被balance过的worker。
那么会不会经历几轮请求之后就能达到balance了呢?如果请求真的是一轮一轮的过来,并且每个请求的处理时间完全相同,那么有可能会达到balance,但是实际情况肯定相差甚远。
解决办法是什么呢?将cond先进先出的队列式等待逻辑改为后进先出的栈式逻辑,或许可以解决问题,但是更好的办法应该是限制worker的数目等于或者略小于”CPU”数目,这样很自然的就balance了。
第二个问题,既然我们承认kernel在各个调度域上的load balance的有意义的,我们server中的receiver线程是不是也可以通过类似的办法来获得收益呢?现在我们吸取了之前的教训,只开了8个worker线程。依靠kernel load balance的作用,这8个线程基本会固定在每一个”CPU”上。假设现在一下子来了4个请求,它们会落到4个不同的”CPU”上,如果运气好,这4个”CPU”分别属于不同的core,那么处理请求的过程就不会涉及CPU资源的争抢;反之,可能形成2个core非常忙、2个core闲着的局面。
要解决这个问题需要做到两点,继续以我们之前的server程序为例。首先,receiver线程要知道各个worker线程都落在哪一个”CPU”上;然后在分派任务时还需要有balance的眼光。要做到第一点,最好是借助sched_affinity功能将线程固定在某个”CPU”上,避免kernel load balance把问题搞复杂了。既然前面我们已经得出了工作线程数等于或略小于CPU数的结论,现在每个线程固定在一个CPU上就是可行的。第二点,我们需要在现有pthread_cond的基础上做一些改进,给进入等待状态的worker线程赋一个优先级,比如每个core的第一个超线程作为第一优先级,第二个超线程为第二优先级。那么在cond唤醒工作线程的时候,我们就可以尽量让worker线程不落到同一个core上。实现上可以利用futex的bitset系列功能,通过bitset来标识优先级,以便在唤醒指定的worker线程。(参阅《linux futex浅析》。)
例子
好了,纸上谈兵讲了这么多,得来点实际的例子验证一下。为了简单,就不写什么server程序了,只需要一个生产者线程和若干消费者线程。生产者线程生成一些任务,通过cond+queue将其传递给消费者线程。为了观察在不同任务负载下的程序表现,我们需要控制任务负载。消费者线程在完成任务后通过另一组cond+queue把任务应答给生产者线程,于是生产者就知道当前有多少个任务正在处理中,以便控制生产新任务的节奏。最后,我们通过观察在不同条件下完成一批任务的时间来体会程序的性能。
这里面比较关键的是任务本身的处理逻辑,既然我们讨论的是CPU的负载,任务肯定应该是CPU密集型的任务。然后,单个任务的处理时间不宜太短,否则可能调度过程会成为程序的瓶颈,体现不出CPU的负载问题;另一方面,单个任务的处理时间也不宜太长,否则后知后觉的kernel load balance也能解决问题,体现不出我们主动做load balance的好处(比如任务处理时间是10秒,kernel load balance花费几十毫秒来解决balance问题其实也无伤大雅)。
代码贴在文章最后,编译出来的bin文件是这样的:
$g++ cond.cpp -pthread -O2 $./a.out usage: ./a.out -j job_kind=shm|calc [-t thread_count=1] [-o job_load=1] [-c job_count=10] [-a affinity=0] [-l] [-f filename="./TEST" -n filelength=128M]
代码里面准备了两种任务逻辑,”-j shm”是mmap一个文件,然后读取上面的数据做一些运算(文件及其长度由-f和-n参数来限定);”-j calc”是做一些算术运算;
“-t”参数指定工作线程的线程数;
“-o”指定任务负载;
“-c”指定单个线程处理任务的个数;
“-a”指定是否设置sched_affinity,并且指明跳几个”CPU”放一个worker线程。比如”-a 1″表示把worker线程顺序固定在1、2、3、……号”CPU”上,而”-a 2″表示固定在2、4、6、……号”CPU”上,以此类推。需要注意的是,邻近的”CPU”号并不表示”CPU”在物理上是邻近的,比如在我测试用的机器上,共24个”CPU”,0~11号是每个core的第一个超线程、12~23是第二个超线程。这个细节需要读/proc/cpuinfo来确定。
“-l”参数指定启用我们增强版的分级cond,启用的话会将0~11号worker作为第一优先级,12~23作为第二优先级(当然,需要配合”-a”参数才有实际意义,否则也不确定这些worker都落在哪些”CPU”上);
首先来看worker线程过多所带来的问题(以下case各运行5次取时间最小值)。
case-1,启240个worker线程,24个任务负载: $./a.out -j calc -t 240 -o 24 total cost: 23790 $./a.out -j shm -t 240 -o 24 total cost: 16827 case-2,启24个worker线程,24个任务负载: $./a.out -j calc -t 24 -o 24 total cost: 23210 $./a.out -j shm -t 24 -o 24 total cost: 16121
case-2效果明显要好略一些。并且在运行过程中如果用top观察的话,你会发现case-1只能压到2200%左右的CPU,而case-2几乎能达到2400%。
在case-1的基础上,如果禁止kernel load balance会怎样?加affinity试试看:
case-3,启240个worker线程,24个任务负载,加affinity: $./a.out -j calc -t 240 -o 24 -a 1 total cost: 27170 $./a.out -j shm -t 240 -o 24 -a 1 total cost: 15351
calc任务比较符合预期,没有kernel load balance的情况下,性能继续下降。
而shm任务则让人大跌眼镜,性能居然提升了!其实这个任务除了CPU之外还很依赖于内存,因为所有任务都工作在同一个文件的mmap上,”CPU”挨得近反而更能发挥内存cache。(可见在这种情况下,kernel load balance其实是帮了倒忙。)
那么,我们将工作线程再调回24,是不是应该更理想?
case-3' $./a.out -j shm -t 24 -o 24 -a 1 total cost: 15133
再来看第二个问题,worker线程站位不均所带来的影响。
case-4,启24个worker线程,12个任务负载: $./a.out -j calc -t 24 -o 12 total cost: 14686 $./a.out -j shm -t 24 -o 12 total cost: 13265 case-5,启24个worker线程,12个任务负载,加affinity,启用分级cond: $./a.out -j calc -t 24 -o 12 -a 1 -l total cost: 12206 $./a.out -j shm -t 24 -o 12 -a 1 -l total cost: 12376
效果还是不错的。改一下”-a”参数,让同一个core的两个超线程都分在同一优先级呢?
case-5' $./a.out -j calc -t 24 -o 12 -a 2 -l total cost: 23510 $./a.out -j shm -t 24 -o 12 -a 2 -l total cost: 15063
由于争抢CPU资源,calc任务性能变得很差,几乎减半。而shm任务由于cache复用所带来的好处,情况还好(比case-3还略好一些)。
这里的任务只是举了calc和shm两个例子,实际情况可能是很复杂的。尽管load balance的问题肯定存在,但是任务会因共享cache而得利、还是因争抢cache而失利?争抢CPU流水线又会造成多大的损失?这些都只能具体问题具体分析。kernel的load balance将负载尽量均摊到离得远的”CPU”上,大多数情况下没有问题。不过我们也看到shm任务中cache共享的收益还是很大的,如果例子更极端一点,肯定会出现承受负载的CPU离得越近,反而效果越好的情况。
另一方面,争抢CPU流水线会有多大损失,也可以简单的分析一下。超线程相当于两个线程共用一套CPU流水线,如果单个线程的代码上下文依赖很严重,指令基本上只能串行工作,无法充分利用流水线,那么流水线的空余能力就可以留给第二个线程使用。反之如果一个线程就能把流水线填满,硬塞两个线程进来肯定就只能有50%的性能(上述calc的例子就差不多是这样)。
为了说明这个问题,我们给calc任务加了一个SERIAL_CALC的宏开关,让它的运算逻辑变成上下文强依赖。然后重跑case-5中的两个命令,我们会看到其实在这种情况下承受负载的CPU离得近一些似乎也问题不大:
case-6,采用SERIAL_CALC运算逻辑,重跑case-5中的calc任务 $g++ cond.cpp -pthread -O2 -DSERIAL_CALC $./a.out -j calc -t 24 -o 12 -a 1 -l total cost: 51269 $./a.out -j calc -t 24 -o 12 -a 2 -l total cost: 56753
最后是代码,有兴趣你还可以尝试更多的case,have fun!
#include <pthread.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/time.h> #include <sched.h> #include <sys/types.h> #include <errno.h> #include <string.h> #include <linux/futex.h> #include <sys/time.h> #include <sys/stat.h> #include <fcntl.h> #include <sys/mman.h> #include <math.h> #include <sys/syscall.h> #define CPUS 24 #define FUTEX_WAIT_BITSET 9 #define FUTEX_WAKE_BITSET 10 struct Job { long _input; long _output; }; class JobRunner { public: virtual void run(Job* job) = 0; }; class ShmJobRunner : public JobRunner { public: ShmJobRunner(const char* filepath, size_t length) : _length(length) { int fd = open(filepath, O_RDONLY); _base = (long*)mmap(NULL, _length*sizeof(long), PROT_READ, MAP_SHARED|MAP_POPULATE, fd, 0); if (_base == MAP_FAILED) { printf("FATAL: mmap %s(%lu) failed!\n", filepath, _length*sizeof(long)); abort(); } close(fd); } virtual void run(Job* job) { long i = job->_input % _length; long j = i + _length - 1; const int step = 4; while (i + step < j) { if (_base[i%_length] * _base[j%_length] > 0) { j -= step; } else { i += step; } } job->_output = _base[i%_length]; } private: const long* _base; size_t _length; }; class CalcJobRunner : public JobRunner { public: virtual void run(Job* job) { long v1 = 1; long v2 = 1; long v3 = 1; for (int i = 0; i < job->_input; i++) { #ifndef SERIAL_CALC v1 += v2 + v3; v2 *= 3; v3 *= 5; #else v1 += v2 + v3; v2 = v1 * 5 + v2 * v3; v3 = v1 * 3 + v1 * v2; #endif } job->_output = v1; } }; class JobRunnerCreator { public: static JobRunner* create(const char* name, const char* filepath, size_t filelength) { if (strcmp(name, "shm") == 0) { printf("share memory job\n"); return new ShmJobRunner(filepath, filelength); } else if (strcmp(name, "calc") == 0) { printf("caculation job\n"); return new CalcJobRunner(); } printf("unknown job '%s'\n", name); return NULL; } }; class Cond { public: virtual void lock() = 0; virtual void unlock() = 0; virtual void wait(size_t) = 0; virtual void wake() = 0; }; class NormalCond : public Cond { public: NormalCond() { pthread_mutex_init(&_mutex, NULL); pthread_cond_init(&_cond, NULL); } ~NormalCond() { pthread_mutex_destroy(&_mutex); pthread_cond_destroy(&_cond); } void lock() { pthread_mutex_lock(&_mutex); } void unlock() { pthread_mutex_unlock(&_mutex); } void wait(size_t) { pthread_cond_wait(&_cond, &_mutex); } void wake() { pthread_cond_signal(&_cond); } private: pthread_mutex_t _mutex; pthread_cond_t _cond; }; class LayeredCond : public Cond { public: LayeredCond(size_t layers = 1) : _value(0), _layers(layers) { pthread_mutex_init(&_mutex, NULL); if (_layers > sizeof(int)*8) { printf("FATAL: cannot support such layer %u (max %u)\n", _layers, sizeof(int)*8); abort(); } _waiters = new size_t[_layers]; memset(_waiters, 0, sizeof(size_t)*_layers); } ~LayeredCond() { pthread_mutex_destroy(&_mutex); delete _waiters; _waiters = NULL; } void lock() { pthread_mutex_lock(&_mutex); } void unlock() { pthread_mutex_unlock(&_mutex); } void wait(size_t layer) { if (layer >= _layers) { printf("FATAL: layer overflow (%u/%u)\n", layer, _layers); abort(); } _waiters[layer]++; while (_value == 0) { int value = _value; unlock(); syscall(__NR_futex, &_value, FUTEX_WAIT_BITSET, value, NULL, NULL, layer2mask(layer)); lock(); } _waiters[layer]--; _value--; } void wake() { int mask = ~0; lock(); for (size_t i = 0; i < _layers; i++) { if (_waiters[i] > 0) { mask = layer2mask(i); break; } } _value++; unlock(); syscall(__NR_futex, &_value, FUTEX_WAKE_BITSET, 1, NULL, NULL, mask); } private: int layer2mask(size_t layer) { return 1 << layer; } private: pthread_mutex_t _mutex; int _value; size_t* _waiters; size_t _layers; }; template<class T> class Stack { public: Stack(size_t size, size_t cond_layers = 0) : _size(size), _sp(0) { _buf = new T*[_size]; _cond = (cond_layers > 0) ? (Cond*)new LayeredCond(cond_layers) : (Cond*)new NormalCond(); } ~Stack() { delete []_buf; delete _cond; } T* pop(size_t layer = 0) { T* ret = NULL; _cond->lock(); do { if (_sp > 0) { ret = _buf[--_sp]; } else { _cond->wait(layer); } } while (ret == NULL); _cond->unlock(); return ret; } void push(T* obj) { _cond->lock(); if (_sp >= _size) { printf("FATAL: stack overflow\n"); abort(); } _buf[_sp++] = obj; _cond->unlock(); _cond->wake(); } private: const size_t _size; size_t _sp; T** _buf; Cond* _cond; }; inline struct timeval cost_begin() { struct timeval tv; gettimeofday(&tv, NULL); return tv; } inline long cost_end(struct timeval &tv) { struct timeval tv2; gettimeofday(&tv2, NULL); tv2.tv_sec -= tv.tv_sec; tv2.tv_usec -= tv.tv_usec; return tv2.tv_sec*1000+tv2.tv_usec/1000; } struct ThreadParam { size_t layer; Stack<Job>* inputQ; Stack<Job>* outputQ; JobRunner* runner; }; void* thread_func(void *data) { size_t layer = ((ThreadParam*)data)->layer; Stack<Job>* inputQ = ((ThreadParam*)data)->inputQ; Stack<Job>* outputQ = ((ThreadParam*)data)->outputQ; JobRunner* runner = ((ThreadParam*)data)->runner; while (1) { Job* job = inputQ->pop(layer); runner->run(job); outputQ->push(job); } return NULL; } void force_cpu(pthread_t t, int n) { cpu_set_t cpus; CPU_ZERO(&cpus); CPU_SET(n, &cpus); if (pthread_setaffinity_np(t, sizeof(cpus), &cpus) != 0) { printf("FATAL: force cpu %d failed: %s\n", n, strerror(errno)); abort(); } } void usage(const char* bin) { printf("usage: %s -j job_kind=shm|calc " "[-t thread_count=1] [-o job_load=1] [-c job_count=10] " "[-a affinity=0] [-l] " "[-f filename="./TEST" -n filelength=128M]\n", bin); abort(); } int main(int argc, char* const* argv) { int THREAD_COUNT = 1; int JOB_LOAD = 1; int JOB_COUNT = 10; int AFFINITY = 0; int LAYER = 0; char JOB_KIND[16] = ""; char FILEPATH[1024] = "./TEST"; size_t LENGTH = 128*1024*1024; for (int i = EOF; (i = getopt(argc, argv, "t:o:c:a:j:lf:n:")) != EOF;) { switch (i) { case 't': THREAD_COUNT = atoi(optarg); break; case 'o': JOB_LOAD = atoi(optarg); break; case 'c': JOB_COUNT = atoi(optarg); break; case 'a': AFFINITY = atoi(optarg); break; case 'l': LAYER = 2; break; case 'j': strncpy(JOB_KIND, optarg, sizeof(JOB_KIND)-1); break; case 'f': strncpy(FILEPATH, optarg, sizeof(FILEPATH)-1); break; case 'n': LENGTH = atoi(optarg); break; default: usage(argv[0]); break; } } JobRunner* runner = JobRunnerCreator::create( JOB_KIND, FILEPATH, LENGTH); if (!runner) { usage(argv[0]); } srand(0); Job jobs[JOB_LOAD]; #ifdef TEST_LOAD for (int i = 0; i < JOB_LOAD; i++) { jobs[i]._input = rand(); struct timeval tv = cost_begin(); runner->run(&jobs[i]); long cost = cost_end(tv); printf("job[%d](%ld)=(%ld) costs: %ld\n", i, jobs[i]._input, jobs[i]._output, cost); } delete runner; return 0; #endif printf("use layer %d\n", LAYER); Stack<Job> inputQ(JOB_LOAD, LAYER); Stack<Job> outputQ(JOB_LOAD, LAYER); pthread_t t; ThreadParam param[THREAD_COUNT]; printf("thread init: "); for (int i = 0; i < THREAD_COUNT; i++) { int cpu = AFFINITY ? (i/AFFINITY+i%AFFINITY*CPUS/2)%CPUS : -1; size_t layer = !!(LAYER && i % CPUS >= CPUS/2); param[i].inputQ = &inputQ; param[i].outputQ = &outputQ; param[i].runner = runner; param[i].layer = layer; pthread_create(&t, NULL, thread_func, (void*)¶m[i]); if (cpu >= 0) { printf("%d(%d|%d),", i, cpu, layer); force_cpu(t, cpu); } else { printf("%d(*|%d),", i, layer); } usleep(1000); } printf("\n"); struct timeval tv = cost_begin(); for (int i = 0; i < JOB_LOAD; i++) { jobs[i]._input = rand(); inputQ.push(&jobs[i]); } for (int i = 0; i < JOB_LOAD*JOB_COUNT; i++) { Job* job = outputQ.pop(); job->_input = rand(); inputQ.push(job); } for (int i = 0; i < JOB_LOAD; i++) { outputQ.pop(); } long cost = cost_end(tv); printf("total cost: %ld\n", cost); delete runner; return 0; }
建议继续学习:
- 浅析C++多线程内存模型 (阅读:7119)
- C++ 多线程编程总结 (阅读:6804)
- 多线程队列的算法优化 (阅读:6526)
- 程序中的“多线程” (阅读:5669)
- php多线程扩展 (阅读:4247)
- 为什么在多线程程序中要慎用volatile关键字? (阅读:4002)
- Ameba , 一个简单的 lua 多线程实现 (阅读:3598)
- 多线程程序中操作的原子性 (阅读:3016)
- 极不和谐的 fork 多线程程序 (阅读:2702)
- linux下多线程的创建与等待详解 (阅读:2684)
扫一扫订阅我的微信号:IT技术博客大学习
- 作者:七伤 来源: 搜索技术博客-淘宝
- 标签: balance 多线程
- 发布时间:2015-01-11 23:49:09
- [46] 界面设计速成
- [43] Oracle MTS模式下 进程地址与会话信
- [43] 视觉调整-设计师 vs. 逻辑
- [42] android 开发入门
- [42] IOS安全–浅谈关于IOS加固的几种方法
- [39] 程序员技术练级攻略
- [39] 图书馆的世界纪录
- [38] 【社会化设计】自我(self)部分――欢迎区
- [38] 如何拿下简短的域名
- [34] 读书笔记-壹百度:百度十年千倍的29条法则