当前位置: 首页 > 科技观察

神话般的Linux,本文将带你了解Linux在多核可扩展性设计上的不足

时间:2023-03-19 01:42:53 科技观察

我其实不想讨论微内核的概念,也不擅长解释这个概念。这是一本百科全书,无奈最近因为鸿蒙的发布,这个话题有点扯远了,实在是经不起诱惑。另外,我一直很喜欢操作系统这个话题,所以就老生常谈吧。说到微内核,它的性能经常因为IPC而饱受诟病。不过,除了这个明显的“瑕疵”之外,其他方面似乎并没有受到太多关注。所以我写了一些稍微不同的东西。我假设的微内核性能“缺陷”是由高开销的IPC引起的(确实如此),那么我继续假设这个IPC性能可以优化,并且已经优化(即使它什么都不做,与硬件技术的发展,所谓的历史短板也会逐渐淡化……)。我不公平地回避了核心问题,这不是很道德,但为了接下来的内容,我不得不这样做。之所以很多人不看好微内核,很大程度上是因为它与Linux内核相差太多。人们认为与Linux内核不同的操作系统内核存在各种缺陷。这是因为Linux内核把我们冲走了。脑。Linux内核的设计固化了人们对操作系统内核的认识,使得Linux内核所做的一切都是对的,反Linux大概率是错误的。Linux内核一定是正确的吗?在我看来,Linux内核只是一个恰好在合适的时间工作的内核,恰好是开源的,让人们第一时间看到了一个操作系统内核的全貌,这并不意味着它一定是正确的。相反,它很可能是错误的。【1990年代,WindowsNT系统首当其冲,却难看出其内涵,《windows internal》风靡一时;UNIX存在争议,但GNU不可用。这个时候,Linux内核满足了所有人的好奇心,如此先入为主,让人觉得这才是一个操作系统该有的样子,而在大多数人的眼里,这才是它唯一的样子。]这篇文章主要讲的是内核的可扩展性。先泼一盆冷水,Linux内核在这方面做的不是很好。诚然,近十年来Linux内核从2.6发展到5.3,在SMP多核扩展方面一直精益求精,但说实话,架构上并没有根本性的调整。如果有比较大的调整,就是:$O(1)$调度算法。SMP处理器域负载均衡算法。percpu数据结构。数据结构解锁。都是细节,没什么了不起的,还有更细致的缓存刷新管理,不用第二天就忘了的东西,吸引了这么多人。这不禁让人想起在交换式以太网出现之前人们不断优化CSMA/CD算法的过程。它也没有让人惊叹。直到交换机的出现,才让人眼前一亮,CSMA/CD几乎被彻底抛弃。因为这不是正确的事情。做出正确转换的核心是仲裁。当一个共享资源一次只能被一个实体访问时,我们称该资源为“必须串行访问的共享资源”。当多个实体想要访问这个资源时,一个一个是不可避免的。两种方案一一对应:哪个更好?让我们来谈谈它。冲突难免会出现,冲突会耽误整体通关的时间。你会选哪一个?现在,让我们暂时忘掉宏内核、微内核、进程隔离、进程切换、缓存刷新、IPC等概念,这些概念对于我们理解重要的事物的本质并没有帮助,相反,它们阻碍了我们构建新的谅解。比如你觉得微内核再好,总会有人跳出来说IPC是微内核的瓶颈。当你提出页表项交换等优化时,有人会说进程切换刷新缓存,注册上下文保存/恢复的开销不小,然后你可能知道一些带有进程PID键值的缓存解决方案,blah,blah呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜.所以,抛开这些,看一个角度:对于必须串行访问的共享资源,正确的做法是引入一个仲裁器来排队调度访问者,而不是让访问者并发竞争锁!所谓操作系统这个概念,没必要,随便叫什么,早期叫监视器,现在叫操作系统吧,但这不代表这个概念有多么神奇.操作系统本来就是用来协调多个进程(这也是一个抽象的概念,你可以叫它任务,没关系)对底层共享资源进行多对一的访问。最典型的资源可能是CPU资源。几乎每个人都知道CPU资源是需要调度的,所以任务调度一直是一个热门话题。你看,CPU并不是被所有的任务同时使用,而是被调度程序允许它使用的任何人使用。调度或仲裁是操作系统的本质。那么,对于系统中共享的文件,socket,路由表等各种资源表,为什么要使用并发竞争呢?!所有的共享资源都应该被调度使用,就像CPU资源一样。如果我们去思考操作系统应该实现的最本质的功能,而不是把Linux当作一个先入为主的标准来思考,我们会发现Linux内核处理并发显然是一种错误的方式!Linux内核大量使用了自旋锁,这显然是从单核向SMP演进时最简单最简单的解决方案,即只要没有问题!确实,单核上的自旋锁不能像字面上表达的那样自旋。在单核这种情况下,Linux的自旋锁实现简单地禁用了抢占。因为,这样一来,就不能保证出问题了。但是当需要支持SMP时,简单的禁用抢占并不能保证不会出问题,所以原地等待锁持有者离开就成了最明显的解决方案。自旋锁一直以这种方式使用到现在。直到今天,自旋锁一直在不断优化,但无论怎么优化,它始终是一个过时的自旋锁。可见Linux内核从一开始就不是为SMP设计的,所以它的并发模式是错误的,至少是不适合的。如果有休息,必须有一个立场。我将使用一组用户态代码来模拟没有仲裁的宏内核和有仲裁的微内核分别如何处理共享资源访问。代码比较简单,所以没有加太多注释。以下代码模拟宏内核访问共享资源时的自旋锁并发竞争模式:#include#include#include#include#include#include#includestaticintcount=0;staticintcurr=0;staticpthread_spinlock_tspin;longlongend,start;inttimer_start=0;inttimer=0;longlonggettime(){structtimebt;ftime(&t);return1000*t.time+t.millitm;}voidprint_result(){printf("%d\n",curr);exit(0);}structnode{structnode*next;void*data;};voiddo_task(){inti=0,j=2,k=0;//为了比较公平,由于模拟微内核的代码使用内存分配,这里也是假的。structnode*tsk=(structnode*)malloc(sizeof(structnode));pthread_spin_lock(&spin);//锁定整个访问计算区间if(timer&&timer_start==0){structitimervaltick={0};timer_start=1;signal(SIGALRM,print_result);tick.it_value.tv_sec=10;tick.it_value.tv_usec=0;setitimer(ITIMER_REAL,&tick,NULL);}if(!timer&&curr==count){end=gettime();printf("%lld\n",end-start);exit(0);}curr++;for(i=0;i<0xff;i++){//做一些耗时计算,模拟类似socket操作。强度可以调整,比如0xff->0xffff,测试是在CPU比较强大的机器上做的,可以调整强度,否则队列开销会压倒模拟任务的开销。k+=i/j;}pthread_spin_unlock(&spin);free(tsk);}void*func(void*arg){while(1){do_task();}}intmain(intargc,char**argv){interr,i;inttcnt;pthread_ttid;count=atoi(argv[1]);tcnt=atoi(argv[2]);if(argc==4){timer=1;}pthread_spin_init(&spin,PTHREAD_PROCESS_PRIVATE);start=gettime();//创建工作线程for(i=0;i#include#include#include#include#include#includestaticintcount=0;staticintcurr=0;longlongend,start;inttimer=0;inttimer_start=0;staticinttotal=0;longlonggettime(){structtimebt;ftime(&t);return1000*t.time+t.millitm;}structnode{structnode*next;void*data;};voidprint_result(){printf("%d\n",total);exit(0);}structnode*head=NULL;structnode*current=NULL;voidinsert(structnode*node){node->data=NULL;node->next=head;head=node;}structnode*delete(){structnode*tempLink=head;head=head->next;returntempLink;}intempty(){returnhead==NULL;}staticpthread_mutex_tmutex=PTHREAD_MUTEX_INITIALIZER;staticpthread_spinlock_tspin;intadd_task(){structnode*tsk=(structnode*)malloc(sizeof(structnode));pthread_spin_lock(&spin);if(timer||curr0xffff,在CPU比较强的机器上测试,让它变强,否则队列开销会压倒模拟任务的开销voiddo_task(){inti=0,j=2,k=0;for(i=0;i<0xff;i++){k+=i/j;}}void*func(void*arg){intret;while(1){ret=add_task();if(!timer&&ret==count){break;}}}void*server_func(void*arg){while(timer||total!=count){structnode*tsk;pthread_spin_lock(&spin);if(empty()){pthread_spin_unlock(&spin);continue;}if(timer&&timer_start==0){structitimervaltick={0};timer_start=1;signal(SIGALRM,print_result);tick.it_value.tv_sec=10;tick.it_value.tv_usec=0;setitimer(ITIMER_REAL,&tick,NULL);}tsk=delete();pthread_spin_unlock(&spin);do_task();free(tsk);total++;}end=gettime();printf("%lld%d\n",end-start,total);exit(0);}intmain(intargc,char**argv){interr,i;inttcnt;pthread_ttid,stid;count=atoi(argv[1]);tcnt=atoi(argv[2]);if(argc==4){timer=1;}pthread_spin_init(&spin,PTHREAD_PROCESS_PRIVATE);//创建服务线程序err=pthread_create(&stid,NULL,server_func,NULL);if(err!=0){exit(1);}start=gettime();//创建工作线程序for(i=0;isk_rcvbuf+sk->sk_sndbuf))){bh_unlock_sock(sk);NET_INC_STATS_BH(net,LINUX_MIB_TCPBACKLOGDROP);gotodiscard_and_relse;}bh_unlock_sock(sk);而在微内核代码中,将类似上述的任务打包,交给单独的服务线程调度执行,大大减少了锁区的延迟。宏内核的隔离上下文并发锁抓取场景需要对整个任务进行加锁,造成巨大的锁抓开销,而微内核只需要对任务队列的入队和出队操作进行加锁。这部分开销与具体任务无关,完全可以预见。接下来我们来对比一下同一个任务的执行情况。在不同CPU数量的约束下,两种模式的时间开销对比图:可以看出,随着CPU数量的增加,模拟宏内核的代码锁开销近似线性增加,而对于模拟宏内核的代码microkernel,虽然锁开销也增加了,但是明显不明显。为什么是这样?请看下面宏内核和微内核的对比图,先看宏内核:再看微内核:这显然是一种更现代的方式,不仅减少了加锁的开销,提高了性能,更重要的是最重要的是大大减少了CPU的空转,提高了CPU的利用率。先来看看模拟宏内核执行10秒的代码的CPU使用率:观察热点,可以猜到是自旋锁:很明显,CPU使用率高到是并没有真正执行有用的任务,而是在自旋中空转。再来看同样情况下模拟微内核的代码的性能:看热点:很明显,还是有spinlock的热点,但是明显减少了很多。在更高的执行效率的保证下,CPU并没有那么高,剩下的空闲时间可以用来执行更有意义的工作流程。本文仅展示定性效果。在实践中,微内核服务进程的任务队列的管理效率会更高。它甚至可以在硬件中实现。[参见交换机背板上的交换网络实现。]说了这么多,可能有人会说,NO,你这两个案例对比的不严谨,你只是模拟共享数据的访问,如果真的是并行可执行代码,那用微内核方案岂不是很必要?它会降低性能吗?没事,变并为串!确实如此,但核心本身是共享的。操作系统本身协调用户进程对底层共享资源的访问。所以真正的并行需要程序员自己设计并行应用。内核本身是共享的。多线程访问共享资源应该严格序列化。并发争锁是最无序的方式,最有效的方式是统一仲裁调度。在我们的日常生活中,我们可以明显地看到和理解为什么排队上车比拥挤上车更有效率。在计算机系统领域,我们在交换式以太网和PCIe中也看到了同样的事情。与CSMA/CD的共享以太网相比,交换机是一个仲裁调度器,PCIe的消息集线器也起着同样的作用。事实上,即使是宏内核在访问共享资源时也不总是使用并发锁争用。对于敏感度高的资源,比如对时延要求高的硬件资源,系统底层也是通过仲裁调度来实现的。比如网卡上层发送数据包的队列调度器,还有对应的磁盘IO的磁盘调度器。但是对于宏内核来说,更高级的逻辑资源,比如VFS文件对象、socket对象、各种队列等,是不会被仲裁调度访问到的。当它们被多个线程并发访问时,令人遗憾的Concurrent竞争锁模式,这也是不得已而为之,因为没有实体可以完成仲裁,毕竟访问它们的上下文是隔离的。让我们插一句。在Linux系统调优的时候,针对这些方面相关的热点基本就可以了。很多热点问题都是由此引起的,打开/关闭同一个文件,进程上下文和softirq同时操作同一个套接字,当接收数据包时,多个CPU上的softirq上下文将数据包放入同一个队列等等。\如果你不打算调优Linux,也许你已经知道Linux内核在SMP环境下的根本缺陷,为什么要调优呢。多看看外面的世界,也许比眼前的世界更好。我们在评价传统的UNIX和Linux操作系统内核时,应该更多地关注它们缺失了什么,而不是盲目地认为它们是对的。[你认为是,也许只是因为它是第一个也是唯一一个你见过的]如果非要讲概念,就必须要讲现代操作系统的虚拟机抽象。对于我们常说的现代操作系统,按照原始的冯诺依曼结构,在多处理(包括所有的多进程、多线程等)机制中只抽象出“CPU和内存”,而对于文件系统、网络栈等。没有多处理抽象。也就是说,现代操作系统为进程提供了独占的虚拟机抽象,它只由CPU和内存组成:时间片调度让进程认为自己拥有独占的CPU。虚拟内存使进程认为它拥有对内存的独占访问权。没有其他虚拟机抽象。当进程使用这些抽象资源时,现代操作系统无疑采用了仲裁调度机制:操作系统提供任务调度器来仲裁CPU的时分复用(典型的是多级反馈优先级队列算法),统一为进程/线程分配资源物理CPU的时间片资源。操作系统提供内存分配算法来仲裁物理内存空间的分配(典型的是伙伴系统算法),统一为进程/线程分配物理内存映射到虚拟内存。显然,正如本文开头所说,操作系统不允许进程并发竞争CPU和内存资源。然而,对于几乎所有其他资源,操作系统并没有做出任何严格的规定。操作系统以两种态度对待它们:它认为其他资源不是操作系统核心的一部分,因此微内核、用户态驱动程序等形成概念。认为其他低级资源也是操作系统核心的一部分,是Linux这样一个宏内核的态度。态度如何并不重要。宏内核、微内核、用户态、内核态,这些只是概念,没什么大不了的。关键问题是:如何协调共享资源的分配。或空间资源,或时间资源,或并发争用锁,或仲裁调度。毫无疑问,最大的争议在于如何协调CPU/内存之外的非进程虚拟化文件系统访问和网络协议栈访问。但是不管是哪一种,宏内核和微内核都有非常非常好的解决方案。遗憾的是,这些伟大的方案都没有被Linux内核采用。哦,顺便说一下,Nginx对微内核、交换机和PCIe采用了类似的方法,但Apache没有。还有很多例子,我就不一一赘述了。我只想说,在操作系统领域,核心的东西是看不见的,不是各种概念。摘自王寅谈微内核:和一些人谈论操作系统很烦人,因为我倾向于抛弃一些术语和概念,从头开始讨论。我试图从“计算本质”的角度来理解这些东西,了解它们的起源、发展、现状和可能的改进。我关心的往往是“这个东西应该是什么样子”、“它可以是什么样子(也许更好)”,而不仅仅是“现在是什么样子”。不了解我这一特点的人,自以为了解什么的人,往往会误以为我连最基本的术语都不懂。于是Tian就被他们聊死了。这其实就是我想说的。所以,忘掉微内核、宏内核、内核态、用户态、实模式、保护模式,这样你就会对如何仲裁共享资源访问的本质有更深刻的理解。