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

为什么Linux系统调用会占用更多资源?

时间:2023-03-14 17:28:06 科技观察

系统调用是计算机程序在执行过程中向操作系统内核申请服务的方法,可能包括硬件相关的服务、新进程的创建和执行、进程调度等.对操作系统稍有了解的人都知道,系统调用为用户程序提供了与操作系统的接口。图1-操作系统接口著名的C语言glibc封装了操作系统提供的系统调用,并提供了定义良好的接口[^2]。工程师可以直接使用设备中封装的功能来开发上层应用,其他编程语言的标准库也封装了系统调用。它们对外提供语言原生接口,内部使用汇编语言触发系统调用。我们在使用标准库的时候,经常需要和系统调用打交道,但是很多时候我们并不知道标准库背后的实现。以常见的HelloWorld程序为例,这么简单的几行函数,在系统实际运行时会执行几十次。呼叫:#includeintmain(){printf("Hello,World!");return0;}$gcchello.c-ohello$strace./helloexecve("./hello",["./hello"],0x7ffd64dd8090/*23vars*/)=0brk(NULL)=0x557b449db000access("/etc/ld.so.nohwcap",F_OK)=-1ENOENT(Nosuchfileordirectory)access("/etc/ld.so.preload",R_OK)=-1ENOENT(Nosuchfileordirectory)openat(AT_FDCWD,"/etc/ld.so.cache",O_RDONLY|O_CLOEXEC)=3fstat(3,{st_mode=S_IFREG|0644,st_size=26133,...})=0mmap(NULL,26133,PROT_READ,MAP_PRIVATE,3,0)=0x7f645455a000close(3)=0...munmap(0x7f645455a000,26133)=0fstat(1,{st_mode=S_IFCHR|0600,st_rdev=makedev(136,0),...})=0brk(NULL)=0x557b449db000brk(0x557b449fc000)=0x557b449fc000write(1,"Hello,World!",13Hello,World!)=13exit_group(0)=?+++exitedwith0+++strace在Linux中使用它是一种监视和篡改进程与内核之间的操作的工具。上面的命令会在hello的执行过程中打印触发系统调用、参数、返回值等信息。执行HelloWorld程序时触发的系统调用,大部分都是在程序启动时触发的。只有munmap之后的系统调用才会被printf函数触发。作为应用程序,我们能做的事情非常有限,很多功能需要操作系统提供。服务。大多数编程语言中的函数调用只需要分配新的栈空间,将参数写入寄存器并执行CALL汇编指令跳转到目标地址执行函数,函数返回时通过栈或寄存器返回参数即可。与函数调用相比,系统调用消耗的资源更多。如下图所示,使用SYSCALL指定系统调用的执行时间是C函数调用的几十倍:图2-系统调用和函数调用的耗时对比图中vDSO的全称是Virtual动态共享对象(vDSO),可以减少系统调用所消耗的时间。后面我们会详细分析它的实现原理。getpid(2)是一个相对较快的系统调用。该系统调用不包含任何参数。它只是切换到内核态,读取变量并返回PID。我们可以将其执行时间作为系统调用的基准;除了getpid(2)之外,使用close(999)系统调用关闭一个不存在的文件描述符消耗的资源更少[^5],比getpid(2)[^6]少大约20个CPU周期,当然,如果你想实现一个系统调用来测试额外的开销,使用自定义的空函数应该是完美的选择,有兴趣的读者可以自己尝试一下。图3系统调用的三种方式从以上系统调用和函数调用的基准测试中我们可以发现,在没有vSDO加速的情况下,系统调用所需要的时间是普通函数调用的几十倍。为什么系统调用会带来这么大的开销,它在内部完成了什么样的工作呢?本文将介绍Linux执行系统调用的三种方法:利用软件中断触发系统调用;使用SYSCALL/SYSENTER等汇编指令触发系统调用;使用虚拟动态共享对象(vDSO)执行系统调用;软件中断interrupt是发送给处理器的输入信号,它可以指示某个时间需要立即由操作系统处理。如果操作系统接收到中断,那么处理处理器将暂停当前任务,存储上下文状态,并执行中断处理程序来处理发生的事件。中断处理程序结束后,当前处理器会恢复上下文,继续完成之前的工作。图4-硬件中断和软件中断根据事件发送者,我们可以将中断分为硬件中断和软件中断。硬件中断是由处理器外部设备触发的电子信号;软件中断是由处理器执行的特定指令触发的,一些特殊指令也可以有意触发软件中断]。在32位x86系统上,我们可以使用INT指令来触发软件中断。早期的Linux会使用INT0x80来触发软件中断并注册一个特定的中断处理程序entry_INT80_32来处理系统调用。下面来看看使用软件中断执行系统调用的具体过程:(1)应用程序通过调用C语言库中的函数发起系统调用;(2)C语言函数通过栈接收调用者传入的参数,将系统调用需要的参数复制到寄存器中;(3)Linux中每个系统调用都有一个特定的序号,函数会将系统调用的序号复制到eax寄存器中;(4)函数执行INT0x80指令,处理器会从用户态切换到内核态,执行Definedprocessor;(5)执行中断处理器entry_INT80_32处理系统调用;执行SAVE_ALL将寄存器的值存入内核栈,调用do_int80_syscall_32;调用do_syscall_32_irqs_on检查系统调用序号是否合法;在系统调用表ia32_sys_call_table中查找相应的系统调用,并传入寄存器的值;系统调用在执行过程中会检查参数的合法性,在用户态内存和内核态内存之间传递数据,系统调用的结果会存放在eax寄存器中;从内核栈中恢复寄存器的值,并将返回值入栈;系统调用返回C函数,包装函数返回结果给应用程序;(6)如果系统调用服务执行过程中发生错误,C语言函数会将错误保存在全局变量errno中,并根据系统调用的结果返回一个整数int表示的状态;图5——系统调用的执行步骤从上面系统调用的执行过程可以看出,基于软件中断的系统调用是一个比较复杂的过程。应用程序通过软件中断进入内核态,查询并执行注册在内核态系统调用表中的函数。整个过程不仅需要将数据存入寄存器,从用户态切换到内核态,还需要完成参数有效性的验证,相比之下确实会带来很多额外的开销随着函数调用的过程。事实上,使用INT0x80来触发系统调用早已不复存在,大多数程序都会尽量避免这种触发方式。但是这个规则并不具有普遍性,因为Go语言团队在做benchmark测试的时候发现INT0x80触发系统调用在一些操作系统上的性能几乎和其他方法一样,所以在Android/386和Linux/386等架构系统上调用仍将使用中断执行。由于使用软件中断实现的系统调用,汇编指令在Pentium4处理器上的性能非常差。为了解决这个问题,Linux在较新的版本中使用了新的汇编指令SYSENTER/SYSCALL。它们是在Intel和AMD上实现快速系统调用的说明。我们将在32位操作系统上使用SYSENTER/SYSEXIT。在64位操作系统上使用SYSCALL/SYSRET:图6-快速系统调用指令上面提到的几个汇编指令是低延迟系统调用和返回指令,它们会认为操作系统实现了Linear-memoryModel(Linear-memoryModel),大大简化了操作系统系统调用和返回的过程,包括不必要的检查、预加载参数等。与软件中断驱动的系统调用相比,使用快速的系统调用指令可以减少25%的时钟周期.线性内存模型是内存寻址的常见范例。在这种模式下,线性内存和应用程序存储在一个单一的连续空间地址中,CPU可以使用地址直接访问可用的内存地址,而无需诉诸内存碎片或分页技术。.在64位操作系统上,我们将使用SYSCALL/SYSRET进入和退出系统调用,这些系统调用将在操作系统的最高权限级别执行。初始化时,内核会调用syscall_init函数将entry_SYSCALL_64存入MSR寄存器(ModelSpecificRegister,MSR)。MSR寄存器是x86指令集中用于调试、跟踪和性能监控的控制寄存器:voidsyscall_init(void){wrmsr(MSR_STAR,0,(__USER32_CS<<16)|__KERNEL_CS);wrmsrl(MSR_LSTAR,(unsignedlong)entry_SYSCALL_64);...}当内核接收到用户程序触发的系统调用时,会读取MSR寄存器中要执行的函数,并按照调用约定读取寄存器中系统调用的编号和参数x86-64。您可以在entry_SYSCALL_64函数的注释中找到相关的调用约定。汇编函数entry_SYSCALL_64在执行过程中会调用do_syscall_64。它的实现有点类似于上一节的do_int80_syscall_32。他们都是在系统调用表中查找函数,在寄存器中传入参数。与INT0x80触发软件中断实现系统调用不同,SYSENTER和SYSCALL是专门为系统调用设计的汇编指令。堆栈和返回地址等信息需要保存,因此可以减少所需的开销。vDSO虚拟动态共享对象(virtualdynamicsharedobject,vDSO)是Linux内核将内核空间的部分功能暴露给用户空间的一种机制。简单的说,我们将Linux内核中不涉及安全的系统调用直接映射到用户空间。这样,用户空间的应用程序在调用这些函数时就不需要切换到内核态,减少性能损失。vDSO使用标准链接和加载技术。作为一个动态链接库,由Linux内核提供并映射到各个执行进程。我们可以使用如下命令查看动态链接库在进程中的位置:$ldd/bin/catlinux-vdso.so.1(0x00007fff2709c000)...$cat/proc/self/maps...7f28953ce000-7f28953cf000r--p00027000fc:012079/lib/x86_64-linux-gnu/ld-2.27.so7f28953cf000-7f28953d0000rw-p00028000fc:012079/lib/x86_64-linux-gnu/ld-2.27.so7f28953d0000-7f28953d1000rw-p0000000000:0007ffe8ca4d000-7ffe8ca6e000rw-p0000000000:000[stack]7ffe8ca8d000-7ffe8ca90000r-p00000000000000000000000000000000000000000000000000000000000000000000:000[VVAR]7FFE8CA90000-7FFE8CA92000R-XP0000000000000000000000000000000000000000000000000000000000000000000000.:000[VDSO][vdso]我们还可以在程序执行过程中看到它在虚拟内存中加载的位置。vDSO可以为用户程序提供虚拟系统调用,它会使用内核提供的数据来模拟用户态的系统调用:图7-内核和用户控件的初始化系统调用gettimeofday就是一个很好的例子,如图上图中,使用vDSO的系统调用gettimeofday会按照以下步骤进行初始化:内核中的ELF加载器会负责映射vDSO的内存页,并在辅助向量(AuxiliaryVector)中设置AT_SYSINFO_EHDR,这存储vDSO的基地址;动态链接编译器会查询辅助向量中的AT_SYSINFO_EHDR。如果设置了这个标签,它会链接vDSO;libc在初始化时会在vDSO中寻找__vdso_gettimeofday符号,并将该符号链接到全局函数指针;大多数架构上的vDSO除了gettimeofday外,还包括clock_gettime、clock_getres和rt_sigreturn三个系统调用。这些系统调用的完成函数比较简单,不会产生安全问题,所以将它们映射到用户空间可以显着提高系统调用的性能。我们在图2中可以看到,使用vDSO可以将上述几个系统调用的时间增加几十倍。总结当我们在编写应用程序的时候,系统调用并不是一个离我们很远的概念。一个简单的HelloWorld在执行过程中会触发几十个系统调用,当线上出现性能问题时,我们可能还需要沟通处理系统调用。虽然程序中的系统调用非常频繁,但与普通的函数调用相比,会带来明显的额外开销:软件中断触发的系统调用需要保存堆栈和返回地址等信息,必须保存在中断描述表中.找到系统调用的响应函数,虽然大多数操作系统不会使用INT0x80来触发系统调用,但是在一些特殊场景下,我们还是需要用到这个古老的技术;使用汇编指令SYSCALL/SYSENTER来执行系统调用是最常用的方法,作为专门为系统调用而创建的指令,可以省去一些不必要的步骤,减少系统调用的开销;使用vSDO执行系统调用是操作系统提供的最快路径,可以使系统调用的开销与函数调用的开销相等,但由于将系统调用从内核态映射到“用户态”》确实存在安全隐患,操作系统只会放过有限数量的系统调用;应用程序能够完成的工作是相当有限的,我们功能丰富的用户程序需要使用操作系统提供的服务。系统调用作为操作系统提供的接口,与底层硬件密切相关。由于硬件的多样性,不同的体系结构使用不同的指令。随着内核的快速演进,要找到准确的资料也非常困难。但是,了解不同系统调用的实现原理,对于我们理解操作系统也是很有帮助的。最后我们来看一些比较开放的相关问题。有兴趣的读者可以仔细思考以下问题:vDSO提供的系统调用rt_sigreturn的作用是什么?vDSO提供的4个系统调用中有3个是和采集时间相关的,为什么它能在用户态提供rt_sigreturn,没有安全隐患吗?