volatile与信号
文章目录volatile 关键字与信号场景下的可见性问题编译器优化问题开启高优化后程序可能无法退出高优化条件下程序不退出的原因volatile关键字编译器优化与寄存器缓存详解volatile 关键字与信号场景下的可见性问题在讨论完信号捕捉、可重入函数等概念之后还需要补充一个与信号机制密切相关的重要语法与系统特性即volatile关键字。需要先说明两点volatile是 C 语言中的标准关键字C 中同样存在。本节引入volatile并不是单纯讲语言语法而是借助它来说明当程序的执行流可能被异步事件打断时编译器优化会对程序行为产生什么影响。编译器优化问题程序定义一个全局变量quit初始值为0主执行流在一个空循环中不断检测该变量同时为SIGINT注册信号处理函数在处理函数中将quit修改为1。示意代码如下#includestdio.h#includestdlib.h#includeunistd.h#includesignal.hintquit0;voidhandler(intsigno){printf(pid: %d, %d 号信号正在被捕捉!\n,getpid(),signo);printf(quit: %d,quit);quit1;printf(- %d\n,quit);}intmain(){signal(2,handler);while(!quit);printf(注意 我是正常退出的!\n);return0;}该程序通过signal为SIGINT注册了一个自定义信号处理函数handler并使用全局标志位quit控制主执行流是否退出。程序启动后如果尚未收到SIGINT则主执行流会一直停留在while(!quit);这一空循环中。由于quit初始值为0表达式!quit的结果始终为真因此循环条件持续成立程序不会继续向下执行。这种写法本质上属于忙等待busy waiting。也就是说进程在循环中不断重复检测quit的值而不执行其他实际业务逻辑。因此运行时通常会观察到该进程占用较高的 CPU 资源。需要注意的是即使这是一个死循环进程也不会长期独占整个 CPU因为操作系统调度器仍然会按照时间片轮转等策略在多个进程之间分配处理器时间。当外部向该进程发送一次SIGINT后内核会中断当前的主执行流转而执行用户注册的信号处理函数handler。在该处理函数中程序首先输出当前进程的PID以及捕捉到的信号编号然后打印变量quit修改前的值接着将quit从0设置为1并再次输出修改后的值。待handler执行结束后程序会从原先被中断的位置恢复执行也就是继续回到while (!quit)的条件判断处。此时如果主执行流能够正确观察到quit已经变为1则表达式!quit的结果为假循环立即结束程序继续向下执行并输出注意我是正常退出的!因此该实验的预期现象是在未收到信号之前进程始终停留在忙等待循环中一旦收到SIGINT信号处理函数将全局标志位quit置为1主循环在后续判断中检测到条件不再成立于是退出循环并正常结束程序。该程序利用信号处理函数异步修改全局退出标志位quit主执行流通过忙等待轮询该标志当SIGINT到达后handler将quit置为1从而使主循环结束进程正常退出。开启高优化后程序可能无法退出如果在编译时启用较高等级的优化例如gcc-O3demo.c-odemo编译器优化与实验现象变化在使用 GCC 或 G 编译程序时编译器通常会对代码进行一定程度的优化。也就是说即使程序员没有显式指定较高的优化选项编译器在默认情况下也可能进行有限的优化只是这种优化通常比较保守不容易在简单实验中直接观察到明显差异。为了更明显地观察优化对程序行为的影响可以手动提高优化级别。例如GCC 常见的优化级别包括-O0-O1-O2-O3其中-O0表示基本不进行优化而-O3表示采用更激进的优化策略。需要注意的是优化级别并不意味着“固定的某一种变换”而是表示编译器会在该级别下启用一组优化策略。由于不同编译器版本、不同平台、不同代码结构都会影响最终优化结果因此编译器实际会把代码优化成什么样并不是完全固定的。在本实验中如果将程序以较高优化级别重新编译例如使用gcc-O3main.c-omain然后再次运行程序并向其发送SIGINT可能会观察到一个与先前实验不同的现象信号处理函数handler确实被执行了quit的值在handler内部也确实从0被修改为1但是主执行流中的while (!quit)循环却没有正常结束程序没有输出“正常退出”的提示而是看起来仍然停留在循环中。从程序逻辑上看这个现象似乎与预期矛盾。因为如果quit已经被改为1那么表达式!quit的结果理应为假循环条件应当失效程序应当退出循环并继续向下执行。然而在高优化条件下程序却可能没有按预期结束。这说明编译器优化可能改变变量的访问方式从而使程序在异步信号场景下表现出与未经优化时不同的运行结果。在开启高等级编译优化后即使信号处理函数已经修改了变量quit主执行流也不一定能够按预期及时感知这一变化。当变量可能被信号处理函数这类异步执行上下文修改时编译器优化可能导致主执行流无法正确观察到该变量的新值。高优化条件下程序不退出的原因在这个实验中程序之所以在开启高等级编译优化后可能无法正常退出根本原因在于编译器优化改变了变量quit的访问方式使主执行流不再按预期反复从内存中读取它的最新值。从计算机执行模型来看程序中的变量最终存放在内存中而 CPU 在执行指令时通常会将数据加载到寄存器中参与运算。也就是说程序运行时数据可能同时体现为两种状态内存中的实际变量值CPU 寄存器中的临时副本对于如下循环while(!quit);从语义上看主执行流本应不断重复以下过程从内存中读取quit的当前值对其进行逻辑非运算判断循环条件是否成立若条件成立则继续下一轮检测。在未进行激进优化时编译器通常会保留这种“反复读取内存中变量值”的行为。因此当信号处理函数把quit从0改为1后主执行流在下一次循环判断时能够重新读到该新值于是!quit的结果变为假循环结束程序正常退出。然而在较高优化级别下编译器可能会根据主执行流本身可见的代码做出如下推断在main的普通控制流中quit并没有被修改while (!quit)循环体为空因此quit在该循环中似乎不会发生变化。一旦编译器接受了这一假设它就可能把quit的值提前加载到寄存器中并在后续循环判断时反复使用寄存器中的缓存值而不再每次都重新从内存读取。换句话说编译器可能把循环条件从“持续访问内存中的quit”优化为“持续检查寄存器中的一个副本”。此时问题就出现了信号处理函数在执行时确实把内存中的quit从0修改为了1但主执行流循环判断所使用的却仍然可能是优化后保存在寄存器中的旧值0。由于寄存器中的缓存值没有被更新因此主循环始终认为quit 0从而导致表达式!quit一直为真循环无法退出。因此这个现象可以概括为信号处理函数修改的是内存中的quit主执行流在高优化下可能读取的是寄存器中的旧副本内存值与寄存器缓存值发生脱节程序逻辑上变量已经改变但主循环仍然观察不到变化实验现象全局变量定义intquit0;程序主体是signal(2,handler);while(!quit);printf(注意我是正常退出的!\n);意思很简单一开始quit 0所以while (!quit)等价于while (1)程序一直在循环如果收到2号信号也就是SIGINT就执行handlerhandler里把quit改成1正常理解下main 再回到while (!quit)判断时就应该退出循环不优化时发生了什么如果编译器优化很低main 每次判断while (!quit)时都会老老实实去内存里看一眼quit当前是多少。可以把它理解成下面这个伪过程从内存读取 quit 如果 quit0就继续循环 再从内存读取 quit 如果 quit0就继续循环 再从内存读取 quit...此时初始状态内存里的quitquit 0main 在循环里不断检查它。这时你发一个SIGINT比如执行kill-2pid或者直接按Ctrl C信号来了以后程序进入handlervoidhandler(intsigno){printf(pid: %d, %d 号信号正在被捕捉!\n,getpid(),signo);printf(quit: %d,quit);quit1;printf(- %d\n,quit);}执行后内存里的quit变成quit 1handler 返回后main 又继续执行while (!quit)。因为这时 main 下一次判断时会重新去内存里读quit它读到的是1!1 0所以循环结束打印注意我是正常退出的!这就是你最开始看到的“正常现象”。高优化时发生了什么现在假设你用gcc-O3main.c-omain编译。编译器看到这段代码while(!quit);它会从main 的普通控制流去分析发现这个循环体里面什么都没有main里面也没有任何代码修改quit于是编译器可能会想既然quit在 main 这里看起来不会变那我没必要每次都去内存读它读一次就够了。于是它可能把quit先读到寄存器里。你可以把它想象成这样register_valuequit;// 先读一次得到 0while(!register_value){}内存和寄存器就“分家”了程序开始运行时内存中quit 0寄存器中r 0然后 main 不是一直看内存里的quit而是一直看寄存器里的rwhile (!r)因为r一直是0所以循环一直成立。你再发一个SIGINT信号来了进入handler。handler里执行quit1;这一步改的是谁改的是内存里的quit。所以此时状态变成内存中quit 1寄存器中r 0问题就在这儿handler改的是内存main 循环判断时看的却还是寄存器里的旧值所以 main 继续判断while (!r)而r还是0所以条件仍然成立程序继续死循环。为什么明明改成 1 了程序还是不退出因为改的是内存中的 quit而 main 用来判断循环的可能是寄存器中的 quit 副本这两个值这时不一样内存 quit 1 寄存器 r 0所以从我们的角度看“变量不是已经改成 1 了吗”但从 main 当前的执行逻辑看“我看的还是寄存器里的 0 啊”于是程序就不退出结合你这段代码现象可以总结成一句话信号处理函数已经把内存中的quit改成了1但在高优化下主循环可能仍然只检查寄存器中缓存的旧值0因此程序依旧停留在while (!quit)中无法正常退出。如果你愿意我可以把这段直接改写成“讲义版”也就是更像教材/课堂笔记的正式表达。本质总结这个问题的本质不是信号处理失败也不是变量赋值失败而是在异步信号场景下编译器基于普通控制流做出的优化假设与程序真实的运行方式发生了冲突。更准确地说编译器认为quit在主执行流中不会变化因此将其缓存到寄存器但实际上quit会被信号处理函数这类异步执行上下文修改。由于优化器没有自动意识到这一点主执行流最终无法及时看到变量的新值。在高优化条件下编译器可能将quit的值缓存到寄存器中而不再反复从内存读取因此即使信号处理函数已经把内存中的quit改为1主执行流仍可能继续使用寄存器中的旧值0导致while (!quit)循环无法结束。为了解决这种“内存已修改但主执行流不可见”的问题需要引入volatile来约束编译器优化。volatile关键字在该程序中主执行流持续执行while(!quit);在未进行激进优化时循环条件的判断通常会反复从内存中读取变量quit的当前值因此当信号处理函数将quit从0修改为1后主执行流能够在后续判断中观察到这一变化并退出循环。但是在较高优化级别下编译器可能认为在main的普通控制流中quit的值没有被修改因此没有必要在每次循环判断时都重新访问内存。基于这一假设编译器可能将quit的值缓存到寄存器中并在后续循环判断中反复使用寄存器中的副本而不再持续从内存中重新读取。这样一来主循环实际检查的就不再是内存中的最新值而是寄存器中已经缓存的旧值。在这种情况下即使信号处理函数已经把内存中的quit修改为1主执行流仍然可能持续读取寄存器中的旧值0从而导致while (!quit)条件始终成立程序无法按预期退出。换言之问题并不在于代码逻辑错误而在于编译器优化改变了变量访问语义使主执行流对内存中真实状态的变化失去了可见性。为了解决这一问题需要使用volatile关键字。例如volatilesig_atomic_tquit0;在 C/C 语境下volatile的一个常见作用可以概括为保持内存可见性。更准确地说它用于告知编译器该变量的值可能在当前可见控制流之外被修改因此对它的访问不应被随意省略、缓存或长期保存在寄存器中。于是在后续生成的代码中编译器通常会保留对该变量的实际读取行为使循环判断能够持续感知内存中的最新值。因此在当前示例中一旦将quit声明为volatile即使仍然使用较高优化级别编译主执行流在执行while (!quit)时也会重新读取quit的当前值当信号处理函数将其置为1后主循环便能够观察到这一变化并正常退出。volatile的作用是防止编译器将可能被异步修改的变量长期缓存于寄存器中从而保证主执行流能够感知内存中该变量的真实变化。在信号处理函数与主执行流共享标志位时应使用volatile sig_atomic_t以降低编译器优化导致可见性异常的风险。编译器优化与寄存器缓存详解是编译器优化改变了变量的访问方式。变量的物理存储位置**像quit这样的全局变量最终存放在内存中。而 CPU 在执行指令时通常会将数据加载到寄存器中进行运算。典型的执行过程可以抽象为取指令fetch译码/分析指令decode执行指令execute必要时写回结果write-back因此从机器执行角度看程序对变量的读取并不一定每次都直接访问内存编译器完全可能将变量值暂存在寄存器中以减少重复访存开销。编译器的优化假设对于如下代码while(!quit){}从主执行流的语义来看循环体内部没有任何语句在main的正常控制流中也没有看到对quit的修改因此编译器可能推断在当前这段控制流中quit的值不会发生变化。在这种前提下优化器可能将对quit的读取“外提”或缓存为寄存器值即只在进入循环前读取一次quit后续循环判断不再重新访问内存而是直接使用寄存器中的旧值反复判断这样一来即使信号处理函数已经把内存中的quit修改为1主循环仍然可能一直使用寄存器中缓存的旧值0从而导致循环无法结束。问题本质异步修改不在普通控制流之内这个问题的关键在于信号处理函数对quit的修改属于异步执行上下文中的修改。从程序员视角看quit的确可能在任意时刻被信号处理函数修改但从编译器优化器视角看如果没有额外语义约束它只会基于当前可见的普通控制流进行推理而不会默认假定“某个异步信号处理函数会在未来改写这个变量”。因此主循环中的quit读取操作就可能被优化得过于激进最终表现为内存中的值已经变化主执行流却没有重新读取该值volatile的作用为了解决这种问题可以将变量声明为volatilevolatileintquit0;volatile的核心作用可以概括为告诉编译器该对象的值可能在当前可见控制流之外被改变因此每次访问该对象时都应当保留相应的读写操作而不能随意消除、缓存或合并。保持内存可见性。更精确地说它意味着不要把对该对象的访问简单优化为“只读一次”不要长期仅依赖寄存器中的缓存值在每次使用它时都应重新按照volatile语义执行访问因此在本例中一旦把quit声明为volatile主循环判断while (!quit)时编译器通常就不能再将其永久缓存为寄存器值而必须保留对该对象的重复读取。这样当信号处理函数把quit改为1后主循环就能观察到该变化并正常退出。更规范的写法volatile sig_atomic_t**在信号处理场景中更推荐的写法不是volatileintquit0;而是volatilesig_atomic_tquit0;示例如下#includestdio.h#includesignal.hvolatilesig_atomic_tquit0;voidhandler(intsigno){quit1;}intmain(void){signal(SIGINT,handler);while(!quit){}printf(I exit normally\n);return0;}原因在于sig_atomic_t是标准保证可在信号处理函数中安全读写的整数类型volatile用于约束编译器优化两者结合适合用于主程序与信号处理函数之间的简单状态通知需要特别说明的边界这里必须强调volatile并不等于“线程同步”或“并发安全”。它主要解决的是编译器是否会省略访问编译器是否会把值长期缓存起来编译器是否会对访问顺序进行某些过度优化它不能直接保证复合操作的原子性多线程之间的完整同步临界区互斥通用并发语义下的内存顺序控制因此在本节这个例子里volatile的意义是成立的因为这里只需要一个非常简单的通信模型信号处理函数负责把标志位从0改为1主执行流反复检测这个标志位示例代码#includestdio.h#includesignal.hvolatilesig_atomic_tquit0;voidhandler(intsigno){quit1;}intmain(void){signal(SIGINT,handler);while(!quit){}printf(I exit normally\n);return0;}原因是sig_atomic_t是标准规定的、适合在信号处理函数中访问的整数类型volatile用于约束编译器优化保证可见性二者结合更符合信号处理中的最小安全通信模型。