Recently in 操作系统 Category

【转载请注明出处:http://donghao.org/uii/ 】

【原理】

现在互联网公司使用的都是多CPU(多核)的服务器了,Linux操作系统会自动把任务分配到不同的处理器上,并尽可能的保持负载均衡。那Linux内核是怎么做到让各个CPU的压力均匀的呢?

做一个负载均衡机制,重点在于:

1. 何时检查并调整负载情况?

2. 如何调整负载?

先看第一个问题。

如果让我这样的庸俗程序员来设计,我第一个想到的就是每隔一段时间检查一次负载是否均衡,不均则调整之,这肯定不是最高效的办法,但肯定是实现上最简单的。实际上,2.6.20版linux kernel的确使用软中断来定时调整多CPU上的压力(调用函数run_rebalance_domains),每秒1次

但每秒一次还是不能满足要求,对很多应用来说,1秒太长了,一秒钟内如果发生负载失衡对很多web应用都是不能接受的,何况其他实时应用。最好kernel能够紧跟进程的变化来调整。

那么,好,我们在进程创建和进程exit的时候检查并调整负载呢?可以,但是不完整,一个进程创建以后如果频繁的睡眠、醒来、睡眠、醒来,它这样折腾对CPU的负载是有影响的,你就不管它了吗?说到底,我们其实关注的是进程是否在使用CPU,而不是它是否诞生了。所以,我们应该在进程睡眠和醒来这两个时间点检查CPU们的负载。

再看第二个问题,怎么调整负载呢?从最繁忙的那个CPU上挪一个进程到最闲的那个CPU上,如果负载还不均衡,就再挪一个进程,如果还不均衡,继续挪....这也是个最笨的方法,但它却真的是linux CPU负载均衡的核心,不过实际的算法在此基础上有很多细化。对于Intel的CPU,压缩在同一个chip上的多核是共享同一个L2的(如下图,里面的一个Processor其实就是一个chip),如果任务能尽可能的分配在同一个chip上,L2 cache就可以继续使用,这对运行速度是有帮助的。所以除非“很不均衡”,否则尽量不要把一个chip上的任务挪到其他chip上。

Intel MUlti-Core CPU

于是,为了应对这种CPU core之间的异质性??在不同的core之间迁移任务,代价不同??Linux kernel引入了sched_domain和sched_group的概念。sched_domain和sched_group的具体原理,可参考刘勃的文章英文资料

【代码剖析】

SMP负载均衡检查或调整在两个内核函数里发生:

1. schedule()。当进程调用了sleep、usleep、poll、epoll、pause时,也就是调用了可能睡去的操作时都会转为内核代码里对schedule()函数的调用。

2. try_to_wake_up() 。说白了就是进程刚才睡了,现在要醒来,那醒来以后跑在哪个CPU上呢?这个选择CPU的过程,也就是负载均衡的过程。

我们先看schedule()的代码,我们忽略函数前面那些和负载均衡无关的代码(本文代码以内核2.6.20版为准):

[kernel/sched.c --> schedule() ]

  3489     cpu = smp_processor_id();
  3490     if (unlikely(!rq->nr_running)) {
  3491         idle_balance(cpu, rq);
  3492         if (!rq->nr_running) {
  3493             next = rq->idle;
  3494             rq->expired_timestamp = 0;
  3495             wake_sleeping_dependent(cpu);
  3496             goto switch_tasks;
  3497         }
  3498     }

每个CPU都有一个运行队列即这里的rq,运行队列里放着该CPU要运行的进程,如果运行队列里没有进程了,就说明当前CPU没有可调度的任务了,那就要调用idle_balance从其它CPU上“平衡”一些(就是挪一些)进程到当前rq里。

再看idle_balance()的实现:

[kernel/sched.c --> idle_balance()]
  2806 /*
  2807  * idle_balance is called by schedule() if this_cpu is about to become
  2808  * idle. Attempts to pull tasks from other CPUs.
  2809  */
  2810 static void idle_balance(int this_cpu, struct rq *this_rq)
  2811 {
  2812     struct sched_domain *sd;
  2813     int pulled_task = 0;
  2814     unsigned long next_balance = jiffies + 60 *  HZ;
  2815
  2816     for_each_domain(this_cpu, sd) {
  2817         unsigned long interval;
  2818
  2819         if (!(sd->flags & SD_LOAD_BALANCE))
  2820             continue;
  2821
  2822         if (sd->flags & SD_BALANCE_NEWIDLE)
  2823             /* If we've pulled tasks over stop searching: */
  2824             pulled_task = load_balance_newidle(this_cpu,
  2825                                 this_rq, sd);
  2826
  2827         interval = msecs_to_jiffies(sd->balance_interval);
  2828         if (time_after(next_balance, sd->last_balance + interval))
  2829             next_balance = sd->last_balance + interval;
  2830         if (pulled_task)
  2831             break;
  2832     }
  2833     if (!pulled_task)
  2834         /*
  2835          * We are going idle. next_balance may be set based on
  2836          * a busy processor. So reset next_balance.
  2837          */
  2838         this_rq->next_balance = next_balance;
  2839 }

从子sched_domain到父sched_domain遍历该CPU对应的domain(2816行),并调用load_balance_newidle,我们继续:

[kernel/sched.c --> load_balance_newidle()]
2730 static int
  2731 load_balance_newidle(int this_cpu, struct rq *this_rq, struct sched_domain *sd)
  2732 {
  2733     struct sched_group *group;
  2734     struct rq *busiest = NULL;
  2735     unsigned long imbalance;
  2736     int nr_moved = 0;
  2737     int sd_idle = 0;
  2738     cpumask_t cpus = CPU_MASK_ALL;
  2739
  2740     /*
  2741      * When power savings policy is enabled for the parent domain, idle
  2742      * sibling can pick up load irrespective of busy siblings. In this case,
  2743      * let the state of idle sibling percolate up as IDLE, instead of
  2744      * portraying it as NOT_IDLE.
  2745      */
  2746     if (sd->flags & SD_SHARE_CPUPOWER &&
  2747         !test_sd_parent(sd, SD_POWERSAVINGS_BALANCE))
  2748         sd_idle = 1;
  2749
  2750     schedstat_inc(sd, lb_cnt[NEWLY_IDLE]);
  2751 redo:
  2752     group = find_busiest_group(sd, this_cpu, &imbalance, NEWLY_IDLE,
  2753                    &sd_idle, &cpus, NULL);
  2754     if (!group) {
  2755         schedstat_inc(sd, lb_nobusyg[NEWLY_IDLE]);
  2756         goto out_balanced;
  2757     }
  2758
  2759     busiest = find_busiest_queue(group, NEWLY_IDLE, imbalance,
  2760                 &cpus);
  2761     if (!busiest) {
  2762         schedstat_inc(sd, lb_nobusyq[NEWLY_IDLE]);
  2763         goto out_balanced;
  2764     }
  2765
  2766     BUG_ON(busiest == this_rq);
  2767
  2768     schedstat_add(sd, lb_imbalance[NEWLY_IDLE], imbalance);
  2769
  2770     nr_moved = 0;
  2771     if (busiest->nr_running > 1) {
  2772         /* Attempt to move tasks */
  2773         double_lock_balance(this_rq, busiest);
  2774         nr_moved = move_tasks(this_rq, this_cpu, busiest,
  2775                     minus_1_or_zero(busiest->nr_running),
  2776                     imbalance, sd, NEWLY_IDLE, NULL);

原来就是我们上面说的“笨办法”,针对当前CPU所属的每个domain(从子到父),找到该sched_domain里最忙的sched_group(2752行),再从该group里找出最忙的运行队列(2759行),最后从该“最忙”运行队列里挑出几个进程到当前CPU的运行队列里。move_tasks函数到底挪多少进程到当前CPU是由第4和第5个参数决定的,第4个参数是指最多挪多少个进程,第5个参数是指最多挪多少“压力”。有了这两个参数限制,就不会挪过头了(即把太多进程挪到当前CPU,造成新的不均衡)。

举个例子,假如有一台8核的机器,两个CPU插槽,也就是两个chip,每个chip上4个核,再假设现在core 4最忙,core 0第二忙,如图:
按照刘勃的文章里的提法,首先是core domain,即Processor 0属于domain 1,Processor 1属于domain 2,其中domain 1包含4个sched_group,每个group对应一个core,如下图(group未画出):
假如现在是 Core 3 在执行idle_balance,则先在domain 1里找最忙的group,找到第二忙的group是core 0(core 4不在domain 1里,所以不会找到它),再从core 0里找最忙的runqueue(运行队列),core 0就一个运行队列,所以直接就是它对应的runqueue了,然后从该runqueue里挪出几个任务到Core 3,这一层domain的均衡做完了。

接着是domain 1的父domain,即 cpu_domain,下图的domain 0:
这个domain 0包含了两个group,每个group对应一个chip,即每个group对应了4个core。
在domain 0找最繁忙的group,显然会找到Processor1 对应的group(因为core 4超忙),那么继续在Processor 1里找最忙的runqueue,于是找到core 4,最后从core 4的runqueue里挑出几个任务挪到core 3,。
这样,整个系统8个核都基本平衡了。

也许有人要问,为什么是从子domain到父domain这样遍历,而不是倒过来,从父到子遍历呢?这是因为子domain通常都是在一个chip上,任务的很多数据在共享的L2 cache上,为了不让其失效,有必要尽量让任务保持在一个chip上。

也许还有人要问:如果core 3本来就是最忙的core,它如果运行idle_balance,会发生什么?答案是什么也不会发生。因为在find_busiest_group函数里,如果发现最忙的是“本CPU”,那么就直接返回NULL,也就不再做任何事。
那core 3岂不永远是最忙的了?呵呵,大家忘了,系统里总有闲的CPU(哪怕是相对比较闲),它总会执行schedule(),就算它从不调用sleep从不睡眠,时钟中断也会迫使其进程切换,进而调用schedule,进而将繁忙CPU的任务揽一部分到自己身上。这样,谁最闲,谁早晚会从忙人身上揽活儿过来,所以忙人不会永远最忙,闲人也不会永远最闲,所以就平等,就均衡了。

再看try_to_wake_up():
[kernel/sched.c --> try_to_wake_up()]
1398 static int try_to_wake_up(struct task_struct *p, unsigned int state, int sync)
  1399 {
            ......
  1417
  1418     cpu = task_cpu(p);
  1419     this_cpu = smp_processor_id();
  1420
  1421 #ifdef CONFIG_SMP
  1422     if (unlikely(task_running(rq, p)))
  1423         goto out_activate;
  1424
  1425     new_cpu = cpu;
  1426
  1427     schedstat_inc(rq, ttwu_cnt);
  1428     if (cpu == this_cpu) {
  1429         schedstat_inc(rq, ttwu_local);
  1430         goto out_set_cpu;
  1431     }

变量this_cpu和变量cpu有什么区别?变量this_cpu是实际运行这个函数的处理器(“目标处理器”),而变量cpu是进程p在睡眠之前运行的处理器??为了方便我们暂且称之为“源处理器”。当然,这两个处理器也可能是同一个,比如进程p在处理器A上运行,然后睡眠,而运行try_to_wake_up的也是处理器A,其实这样就最好了,进程p在处理器A里cache的数据都不用动,直接让A运行p就行了??这就是1428行的逻辑。

如果this_cpu和cpu不是同一个处理器,那么代码继续:
  1447     if (this_sd) {
  1448         int idx = this_sd->wake_idx;
  1449         unsigned int imbalance;
  1450
  1451         imbalance = 100 + (this_sd->imbalance_pct - 100) / 2;
  1452
  1453         load = source_load(cpu, idx);
  1454         this_load = target_load(this_cpu, idx);
  1455
  1456         new_cpu = this_cpu; /* Wake to this CPU if we can */
  1457
  1458         if (this_sd->flags & SD_WAKE_AFFINE) {
  1459             unsigned long tl = this_load;
  1460             unsigned long tl_per_task;
  1461
  1462             tl_per_task = cpu_avg_load_per_task(this_cpu);
  1463
  1464             /*
  1465              * If sync wakeup then subtract the (maximum possible)
  1466              * effect of the currently running task from the load
  1467              * of the current CPU:
  1468              */
  1469             if (sync)
  1470                 tl -= current->load_weight;
  1471
  1472             if ((tl <= load &&
  1473                 tl + target_load(cpu, idx) <= tl_per_task) ||
  1474                 100*(tl + p->load_weight) <= imbalance*load) {
  1475                 /*
  1476                  * This domain has SD_WAKE_AFFINE and
  1477                  * p is cache cold in this domain, and
  1478                  * there is no bad imbalance.
  1479                  */
  1480                 schedstat_inc(this_sd, ttwu_move_affine);
  1481                 goto out_set_cpu;
  1482             }
  1483         }

计算出“目标处理器”和“源处理器”各自的负载(1453行和1454行),再计算“目标处理器”上的每任务平均负载 tl_per_task,最后进行判断:如果“目标处理器”的负载小于“源处理器”的负载且两处理器负载相加都比 tl_per_task小的话,唤醒的进程转为“目标处理器”执行。还有一种情况就是1474行的判断,如果“目标处理器”的负载加上被唤醒的进程的负载后,还比“源处理器”的负载(乘以imbalance后)的小的话,也要把唤醒的进程转为“目标处理器”执行。如果两个因素都不满足,那还是由p进程原来呆的那个CPU(即”源处理器“)继续来处理吧。

有点儿绕,是吧?其实代码虽绕,用意是简单的:

1472行-1473行其实是这样一个用意:如果“目标处理器”的负载很小,小得即使把压力全给到“源处理器”上去也不会超过“源处理器”上的平均任务负载,那么这“目标处理器”的负载是真的很小,值得把p进程挪过来。
1474行的用意则是:如果我们真的把p进程挪到“目标处理器”以后,“目标处理器”的压力也不比“源处理器”大多少,所以,还是值得一挪。

说来说去,还是那个笨原则:把任务从最忙的CPU那儿转到很闲的CPU这儿。

我们已经看过了睡眠和醒来时的内核函数,那么软中断里的run_rebalance_domains又干了些什么呢?其实也很简单,它调用了load_balance函数,而这个函数和load_balance_newidle实现上基本一样,就不累述了。

这里没有探讨进程优先级和进程负载的计算方法,因为太复杂我也不太理解,以后看代码如果有心得,再与大家分享。

linux内核里对于要发送到网络上去的包,是通过sk_buffer结构组织的,这个结构主要就是包含一个指针和一个长度,指针指向要发送数据的开头处,长度当然就是要发送的长度。当我们使用send系统调用时,内核要把用户空间的数据拷往内核空间在(创建一个副本),然后构造sk_buffer指向这个内核空间里的副本,接着把sk_buffer排到队列里去,等待网卡处理。
后来诞生了sendfile,sendfile会直接将sK_buffer指向文件在内存中的cache,这样就节省了一次拷贝。
这里有个注意事项:sendfile不适合频繁改动的文件。假如你调用了一次sendfile,sk_buffer指向文件的某块cache,然后被放入队列;接着,你改动了文件的内容,那么排在发送队列里的那个sk_buffer指向的cache内容就变化了!那发出去的消息就是你改动后的数据。在不知道sk_buffer是否已发往网络的情况下,一边改动文件一边调用sendfile会造成发送出去的内容不确定。所以,sendfile更适合用在静态文件的场合。
当然,如果有个办法能查询sk_buffer是否真的发送出去了(而不是呆在发送队列里),那么“改一次文件内容,调一次sendfile;再改一次文件内容,再调一次sendfile”也是一个不错的节省拷贝时间的发送策略。
做一个文件系统(姑且叫它“蝗虫文件系统“),需要在进程退出时自动删除它打开的文件。另外此文件还支持poll(我自己做的,一般的文件系统不支持),支持poll当然需要等待队列,所以我把一个等待队列放在文件对应的struct inode里,实现该文件struct file_operations里的flush方法,在flush时删除文件并释放该等待队列(kfree)。
这样做似乎一切顺利,直到应用程序开始使用epoll:先在蝗虫文件系统里创建几个文件,再把它们的fd放入epoll(epoll_ctl),然后进程退出,如此多来几次,内核就panic了。还好我用的是QEMU,可以清楚看见在什么地方panic的,结果是 __fput --> eventpoll_release --> eventpoll_release_file --> ep_remove --> ep_unregister_pollwait --> remove_wait_queue,看看ep_unregister_pollwait的代码:

[fs/eventpoll.c --> ep_unregister_pollwait]
  1109 static void ep_unregister_pollwait(struct eventpoll *ep, struct epitem *epi)
  1110 {
  1111     int nwait;
  1112     struct list_head *lsthead = &epi->pwqlist;
  1113     struct eppoll_entry *pwq;
  1114
  1115     /* This is called without locks, so we need the atomic exchange */
  1116     nwait = xchg(&epi->nwait, 0);
  1117
  1118     if (nwait) {
  1119         while (!list_empty(lsthead)) {
  1120             pwq = list_entry(lsthead->next, struct eppoll_entry, llink);
  1121
  1122             ep_list_del(&pwq->llink);
  1123             remove_wait_queue(pwq->whead, &pwq->wait);
  1124             kmem_cache_free(pwq_cache, pwq);
  1125         }
  1126     }
  1127 }

在把多个文件句柄加入epoll里的时候,这些文件句柄对应的inode上的等待队列要被epoll挂在一个数据结构上(struct eppoll_entry),当进程退出,这些等待队列当然要从数据结构上拿掉,但是,这些等待队列已经被我kfree了,所以panic。看来把kfree放在flush是不行了,那放哪儿?有哪个调用是比eventpoll_release发生的更晚的?有!

[fs/file_table.c --> __fput]
  153 void fastcall __fput(struct file *file)
  154 {
  155     struct dentry *dentry = file->f_dentry;
  156     struct vfsmount *mnt = file->f_vfsmnt;
  157     struct inode *inode = dentry->d_inode;
  158
  159     might_sleep();
  160
  161     fsnotify_close(file);
  162     /*
  163      * The function eventpoll_release() should be the first called
  164      * in the file cleanup chain.
  165      */
  166     eventpoll_release(file);
  167     locks_remove_flock(file);
  168
  169     if (file->f_op && file->f_op->release)
  170         file->f_op->release(inode, file);
  171     security_file_free(file);
  172     if (unlikely(inode->i_cdev != NULL))
  173         cdev_put(inode->i_cdev);
  174     fops_put(file->f_op);
  175     if (file->f_mode & FMODE_WRITE)
  176         put_write_access(inode);
  177     file_kill(file);
  178     file->f_dentry = NULL;
  179     file->f_vfsmnt = NULL;
  180     file_free(file);
  181     dput(dentry);
  182     mntput(mnt);
  183 }

struct file_operations里的release方法就是在epoll释放之后调用的,所以应该把kfree挪到release里去执行。
照此改之,没有panic了。
补充了一篇epoll里EPOLLET标记的代码剖析:



QEMU 和bochs严格意义上说不是虚拟机,是“模拟机”,他们把guest os执行的指令由软件来解析执行(软CPU),而xen一类的虚拟机是把指令交给CPU(真正的CPU)来执行。模拟比虚拟慢,但更灵活,在单cpu的机 器上可以模拟多CPU的环境,这是xen做不到的。


05年的时候我就试用过bochs,觉得还不错。但这次用它来装rhel as4 超级慢,配置网络也费了半天劲。于是改用QEMU,发觉确实比bochs好:


1. 速度比bochs快

2. 不用配置文件,命令行很方便

3. 配置网络更方便

4. 自带vncserver,远程访问方便

5. 更妙的是,QEMU可以直接使用bochs生成的虚拟硬盘文件,平滑迁移

6. 最近还听说kvm和QEMU配合威力巨大,不过资源有限,没来得及尝试


只用一行就启动了一个64位、4CPU的模拟机器,还启动了vnc和ne2000的模拟网卡:

qemu-system-x86_64 -smp 4 -m 1G -hda rhel4_x86.img -vnc localhost:0 -net nic,model=ne2k_pci -net user


Xen是通过修改内核代码实现的,所以很多在xen上测试通过的驱动换到真实机器上却不能工作了。

对于驱动开发,还是QEMU搭个虚拟机比较好。


=== 2010.3.25 ===

QEMU从0.12系列开始就不再支持kqemu,我估计自己做了优化,因为我看0.12的速度还行。

装了KVM以后,QEMU不再只是单进程单CPU,而是可以用上多个CPU,速度当然更快,但是配了KVM后也有副作用:不再是模拟,而是虚拟,虚拟机的问题会影响到主机。

我在KVM+QEMU的虚拟机上跑了个错误的驱动,最后不仅这台虚拟机,连主机都挂了。


=== 2010.4.20 ===

发现QEMU有个缺陷:我装完了虚拟系统,想加一块虚拟硬盘,于是qemu-img create了一个5G的文件,然后 -hdb 设为第二块硬盘,但是进入虚拟系统以后,不管我怎么fdisk,它就是认为这块新硬盘只有1G (我的虚拟系统是rhel5,不应该是OS的问题)。


作者:董昊 (要转载的同学帮忙把名字和博客链接http://donghao.org/uii/带上,多谢了!)


linux 上,进程退出会自动flush文件,自动close fd,自动释放占用的内存空间,等等,这些我们都知道了,不用管它。设想我们有个应用,进程退出前要做一个删除文件的操作,但是万一这个进程被别人 kill了或者自己coredump了,那这个操作就完不成了,怎么办?


不要再拘泥于OS之上的应用程序了!我们做一个虚拟设备解决这个问题:做一个miscdevice,然后实现它file_operations里的flush方法,在里面加上删文件的操作。在启动应用进程时打开这个miscdevice,然后,不管什么情况,只要该进程挂了,就会调用flush(这是linux kernel做的,dirver不用实现),而flush就会执行删除文件的操作(这是要自己写到驱动里)。


下面是代码示例:


int sample_flush(struct file* file)

{

    down(&inode_mutex);


    if (file)

    {

        atomic_inc(&file->f_count);

       

        if (file->f_dentry)

        {

            dget(file->f_dentry)

           

            node = file->f_dentry->d_inode;

       

            if (node)

            {

                atomic_inc(&node->i_count);

               

                if (node->i_nlink)

                {

                    parent = file->f_dentry->d_parent;

                   

                    if (!IS_ERR(file->f_dentry))

                    {

                        if (parent->d_inode)

                            vfs_unlink(parent->d_inode, file->f_dentry);

                    }

                }

               

                iput(node);

            }

           

            dput(file->f_dentry);

        }


        atomic_dec(&file->f_count);

    }


    up(&inode_mutex);

}


struct file_operations sample_fops =

{

    .flush = sample_flush;

}


很多人会被上面的代码搞晕:这么如此多的get/put和down/up?没办法,这就是内核代码,要应付各种数据结构的引用记数,还要应付多核,真正核心的其实只有这个vfs_unlink而已。


这只是个例子,flush能做的不仅是删个文件,还可以断个连接,发个邮件,躲个猫猫......OS能做的它都能做,当然,驱动也要靠硬件运转,如果机器直接宕机,那这个操作肯定是做不了。

关于存档

This page is a archive of recent entries in the 操作系统 category.

影视游戏 is the previous category.

对话收录 is the next category.

Find recent content on the main index or look in the 存档 to find all content.