【函数调用需要哪些开销,内联函数又做了什么?】
系列文章目录
? ? ? ? 欢迎大家订阅我的《计算机底层原理》、《自顶向下看Java》专栏,我会持续为大家输出优质内容,能够帮助到各位就是对我最大的鼓励!
目录
一、函数调用需要哪些开销
1.压栈于弹栈开销:
? ? ? ? 当函数被调用的时候、参数局部变量、返回地址等信息全部需要被压入调用栈,也就是我们平常所说的函数栈帧,函数执行完毕以后这些信息需要全部被弹出栈帧,这些过程涉及到了栈的管理,需要CPU一定的开销。
2.寄存器保存于恢复开销:
? ? ? ? 函数的调用过程当中少不了进行各种运算以及各种临时变量的使用,那么也就必然少不了用寄存器来存储各种临时变量和中间结果,但是在函数调用的时候,寄存器当中的值可能需要被保存到内存,需要给下一个调用的函数使用,函数执行结束以后再从内存当中恢复。
? ? ? ? 针对从内存中恢复这一点我多提一句:当程序执行函数的时候、当前函数的执行状态包括寄存器当中的值是需要被保存的,以便在调用另一个函数之后能够恢复,这是因为在函数调用过程当中,新的函数可能会使用相同的寄存器来存储临时变量和中间结果,为了避免冲突,当前函数的寄存器当中的值通常要被保存到内存当中,然后在新的函数调用完成之后再从内存当中讲原来的函数数据恢复。
? ? ? ? 这个过程涉及到了两个关键的步骤:保存和恢复。
? ? ? ? 保存:在函数调用之前,当前函数的执行状态(包括寄存器当中的值)被保存到内存当中的某个位置,这确保了在调用其他函数的时候寄存器的内容不会被覆盖找不到或者意外修改。
? ? ? ? 恢复:当调用的函数执行完成之后,需要将之前保存的执行状态给恢复过来,也包括寄存器当中的值,这样当前函数可以继续执行,并且在寄存器当中的值仍然是正确的。
? ? ? ? 这个过程的目的是确保在函数调用的层次结构当中,每个函数都能够正常使用寄存器而不受其他函数的影响,这种保存和恢复的操作会引入一些开销,在性能优化的上下文当中,程序员通常需要考虑如何减少这些开销,以提高程序运行的效率。
? ? ? ? 好了说到这里的时候可能还会有很多的小伙伴一头雾水,始终不明白,一个函数好端端地执行着,干嘛要保存起来去执行另外一个函数呢?有同学会认为这个过程是中断的过程,其实不然,这个过程类似于中断,但并不是中断,因为函数本身调用的过程当中是大概率会调用到另外一个函数的,注意是大概率不是一定。
? ? ? ? 这个过程涉及到了函数调用和程序的控制流,虽然中断也涉及到了恢复和保存但是这里我们主要是在描述函数调用的情景。
? ? ? ? 在程序执行过程当中函数之间的调用时非常常见的,一个函数执行好好的,但是程序可能需要执行其他功能或者子功能,这个时候就需要调用另外一个函数,在函数调用的过程当中,当前函数的执行状态(包括寄存器当中的值)需要被保存,以确保调用的函数能够被正常执行而不受之前的影响。
? ? ? ? 我举一个例子,就比如我们所熟知的主函数 main(),它要调用一个名为Add的函数计算一些数据的和,在调用这个Add函数之前,主函数需要保存当前的执行状态包括寄存器当中的值,然后Add函数执行完毕之后,主函数需要恢复到之前的状态继续执行。代码如下:
#include"Yangon.h" int Add(int a, int b) { int c = a + b; return c; } int main() { int ret = Add(2, 4); cout << ret << endl; return 0; }
3.参数传递开销
? ? ? ? 参数传递的方式有值传递、引用传递还有指针传递,传递方式的不同开销也会不同,传递一些占用内存很大空间的对象的时候,可能会涉及到复制或者传递引用或者指针的开销。
4.栈帧生成开销
? ? ? ? 函数的每一次调用都会生成一个新的栈帧,其中包含了局部变量、参数、返回地址等信息,栈帧的生成于销毁都会引入开销。具体的开销分为以下几个部分。
? ? ? ? 生成栈帧开销:首先需要分配内存空间,系统会自动地位新的栈帧分配空间,这通常涉及到将栈指针移动到新的位置,并且在新的位置上为栈帧分配足够多的空间。分配好空间之后下一步就是初始化,栈帧内的一些信息,比如局部变量和参数、可能需要被初始化,这包括将参数传递到栈帧中,以及讲过局部变量的初始值设定为默认值或者特定的初始值。
? ? ? ? 执行函数体:函数体当中的代码开始执行,操作局部变量、参数等信息、这不是栈帧生成本身的开销,但是在栈帧生成的过程当中函数体的执行也是一个开销。
? ? ? ? 栈帧销毁开销:当函数执行完毕之后,系统需要立刻释放该函数的栈帧占用的内存空间,这通常涉及将栈指针移动会到调用函数最开始的位置,从而丢弃整个栈帧。但是在销毁栈帧之前可能会需要进行一些清理的工作,比如释放动态分配的内存,关闭文件包等。
? ? ? ? 返回过程:如果这个函数又返回值、需要将返回值传递给调用函数、这可能涉及将返回值存储在特定的寄存器当中或者放置在约定好的内存之上。这些过程当中的每一步都会引入一些开销,尤其是在频繁地调用函数的场景当中。为了提高性能、编译器和程序员都需要进行一些优化策略,比如使用寄存器来传递参数和返回值减少不必要的栈帧操作。
5.寻址开销
? ? ? ? 函数调用的时候需要寻找函数的入口地址,这可能涉及到符号表的查找或者跳转指令,引入一定的寻址开销。在程序执行的过程当中,当一个函数被调用的时候、需要找到该函数的入口地址,以便能够跳转到正确的位置执行函数体。
? ? ? ? 符号表查找:编译时符号解析过程当中,编译器会生成符号表,其中包含了函数名和对应的入口地址,当程序执行的过程当中调用函数需要根据符号表来进行,以获取函数的入口地址。
? ? ? ? 跳转指令:跳转到函数的入口,一旦找到函数的入口地址,程序需要执行跳转指令(例如函数跳转指令或者函数调用指令)来转移到函数的起始位置,这个过程可能涉及到将程序计数器或者指令寄存器的值设置为函数的入口地址。
? ? ? ? 寻址开销影响:符号表查找和跳转指令可能均未命中,因为这些信息可能不再缓存当中,缓存未命中就会引入额外的开销,因为需要从内存当中加载相关信息。而且跳转指令也会影响流水线的性能,因为跳转会引入分支,现代处理器通常使用分支预测机制来减小分支带来的性能影响,但是如果分支预测失效也会引入一定的开销。(这部分的内容涉及到了计算机底层原理,如果有兴趣的小伙伴可以去看我之前的文章)。
? ? ? ? 所以这个时候为了减少开销,编译器和硬件都需要采用一些优化策略,例如使用相对寻址而不是绝对寻址,采用间接寻址等。
6.代码段切换开销
? ? ? ? 如果这个函数不再当前执行的代码段当中,可能需要进行代码段的切换,这也会引入一些开销。代码段切换通常指的是当前程序从一个代码段切换到另外一个代码段时所引入的开销,这种切换通常发生在函数调用或者模块加载等场景当中,下面详细解释一下。
? ? ? ? 加载新的代码段:当程序执行到一个新的函数或者模块的时候,可能需要将新的代码段加载到内存当中去,这就涉及到从磁盘或者从其他存储介质当中读取代码,并且将其加载到可执行内存区域,代码加载通常伴随着地址映射和分页操作,确保代码在内存当中的正确位置,并且可以被正确访问。
? ? ? ? 切换代码段:当程序从一个代码段切换到另外一个代码段时,需要调整程序计数器或者指令寄存器的值,以指向新代码的入口地址
? ? ? ? 缓存效应:切换代码段可能导致缓存未命中,因为新的代码段的指令和数据有可能不再缓存当中,这也会引入额外的开销,因为需要从主内存当中加载新代码段的内容。
7.上下文切换的开销
? ? ? ? 在多线程或者多任务环境当中由于上下文的切换必然会引入额外的开销,首先我们要知道什么是上下文切换,上下文切换是指在多任务(或者多线程当中)环境当中从一个任务切换到另一个任务时,保存当前任务的执行状态,包括寄存器的值,程序计数器的值等,并加载另一个任务的执行状态,这种切换会引入额外的开销。我下面为大家进行详细地讲解。
? ? ? ? 保存当前上下文:在进行上下文切换之前,系统需要保存当前任务(线程)的执行状态,这包括寄存器的内容,程序计数器的值,堆栈指针等,这些信息通常保存在任务的上下文块当中。
? ? ? ? 加载新的上下文:切换到另一个任务的时候,系统需要加载该任务的执行状态,这涉及到将寄存器、程序计数器的值从任务的上下文块当中恢复,以确保任务能够从上次中断的地方继续开始执行。
? ? ? ?调度开销:上下文的切换通常伴随着调度操作,即选择下一个要执行的任务、调度器需要考虑任务的优先级,时间片等信息,这也会引入一些开销。
? ? ? ?用户态和内核态的切换:在多任务环境当中,上下文切换可能涉及到用户态和内核态之间的切换,这通常需要额外的指令和特权级的转换。
? ? ? ? 这里涉及到了很多操作系统的知识,这不是我今天要讲的重点所以就不提了,另外多说一句,任务指的是进程或者线程,而上下文的意思是值任务或者程序在执行的过程当中的当前状态和环境,这个状态包括了一系列的信息,使得程序或者任务被暂停的时候,可以保存当前的执行状态,以便之后能够正确地恢复和执行,上下文的内容通常包括寄存器的内容、内存的状态(堆、栈、数据区等信息)、处理器状态、文件描述符和I/O状态吗,其他状态信息。
8.内存访问开销
? ? ? ? 当函数调用的时候、可能会引起新的内存访问、例如访问函数体内的局部变量,参数等,这会影响缓存的命中率。内存访问开销指的是在程序执行过程当中由于函数调用而引起的内存读取或者写入操作带来的可能的开销,这一过程可能包括对局部变量、参数、堆栈全局变量等的内存访问,对缓存的影响是其中一个关键因素,下面我为大家详细介绍关于内存访问开销的一些相关因素。
? ? ? ? 局部变量和参数:当函数被调用的时候,局部变量和参数通常存储在栈上,访问这些局部变量和参数可能导致缓存未命中,因为这些参数不再缓存当中,如果局部变量被频繁地访问,但是又没有很好地利用缓存可能会导致缓存的命中率下降、增加访问内存的开销,因为缓存虽然容量小但是速度远快于内存。
? ? ? ? 栈操作:函数调用和返回的过程当中,栈上的数据被频繁地读写,这可能导致缓存行的失效,特别是如果相邻的栈帧被频繁地读写。
? ? ? ? 全局变量和静态变量:全局变量和静态变量通常存储在数据段或者BSS段当中、访问这些变量可能会引起缓存未命中尤其是在全局变量较多的程序当中。数据段和BSS段都是静态区当中的区域,数据段用于存储已经初始化的全局变量而BSS段则用于存储未初始化的全局变量和静态变量,因为这些数据存储于静态区,而静态区是内存的空间,所以访问这些变量的时候可能就会引起缓存未命中,这意味着处理器在尝试访问这些变量的时候发现它们不再缓存当中,许需要从内存当中加载,这可能就会导致额外的访存延迟,影响程序的性能,特别是在又大量的全局变量的程序当中。
? ? ? ? 堆操作:如果函数调用导致对堆内存的操作(如动态内存分配和释放),这也可能会影响缓存的性能,频繁的堆操作可能导致缓存行失效,尤其是在多线程的环境当中
9.返回值传递开销
? ? ? ? 返回值的传丢方式不同也会影响开销,返回较大的对象的时候可能会需要进行复制或者返回引用或者指针,这里接涉及到了值传递和引用传递还有址传递。
10.函数调用的指令开销
? ? ? ? 生成和执行函数调用的指令本身也会需要一些开销。在大多数情况下这些开销相对较小,但是在对性能要求较高的场景中,可以考虑使用内联函数避免过多的函数调用,接下来我就为大家详细讲解内联函数做了什么。
二、内联函数省略掉了哪些开销
1.函数调用开销
? ? ? ? 内联函数会在编译的时候将函数体的代码展开插入到调用处,而不是通过函数调用的方式,这避免了函数调用和返回时的栈帧生成与开销。
2.参数传递开销
? ? ? ? 内联函数可以避免一部分参数传递的开销,因为参数值直接被插入到调用点,而不需要通过栈或者寄存器传递。
3.寄存器保存与恢复开销
? ? ? ? 内联函数的调用避免了函数调用时候的栈帧生成,因此不需要保存和恢复寄存器当中的值。
4.代码段切换开销
? ? ? ? 内联函数已经将代码段直接插入到了调用处、不需要进行代码段的切换。
5.指令开销
? ? ? ? 内联函数避免了生成和执行函数调用的指令本身的开销。
6.返回值传递开销
? ? ? ? 返回值也可以直接插入到调用点,减少一次拷贝的开销。
7.函数调用指令的开销
? ? ? ? 函数体直接插入到了调用点,自然也就不需要函数调用的指令开销了。
8.内存访问开销
? ? ? ? 内联函数可以减少内存访问的开销,因为局部变量和参数直接嵌入到了函数调用点。
?三、内联函数到底做了什么?
? ? ? ? 到了这里可能已经又很多小伙伴明白了,所谓的内联函数顾名思义,就是编译器在编译的时候在函数的调用处直接将函数体展开,这样可以省却很多的开销,很多短小却频繁调用的函数需要使用内联函数进行优化,内联函数的关键字是inline。
#include <iostream> using namespace std; inline int Add(int a,int b){ int c = a + b; return c; } int main() { int ret = 0; for(int i = 0;i <= 100; i++){ ret += Add(i,i+1); } return 0; }
? ? ? ? 例如这个地方这个函数如果内部并不复杂而且多次被调用的话,建议使用内联函数进行优化,但是并不是程序员只要使用inline修饰,编译器就一定会将这个函数当作内联函数来处理,这个关键字对于编译器来说知识一个建议,因为内联函数并不是尽善尽美,因为每一次遇到内联函数都要将其展开的话,我们编译生成的目标文件就会变得膨胀冗余,所以内联函数也要合理使用。
总结
? ? ? ? 这篇文章主要为大家讲解了函数调用所需要的开销以及内联函数的功能,内联函数可以为函数的调用节省掉许多的开销,这篇文章涉及到了很多关于函数调用的知识,这其中又穿插了很多关于操作系统和计算机组成原理的相关知识,不管是考研还是就业这部分的知识都是非常有用的,希望能够帮助到大家!
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!