看到题目你也许会纳闷,优化是让事物更优,怎么会有害呢?其实道理很简单,任何事物都具有两面性,“抬杠”的辩证法不就是这么教导我们的么?
闲话少说,切入正题,这篇文章讨论的是C/C++程序的优化,主要包含语言层面的volatile关键字,CPU读写操作的乱序执行和由CPU内部cache可能导致的数据不一致。
volatile关键字
它是C/C++语言的关键字,用来阻止编译器对其进行优化。那么编译器可能对其进行何种有害的优化呢?请看如下代码:
#include <stdio.h>
#include <pthread.h>
static int over = 0;
void* worker(void *args)
{
int i;
for (i = 0; i < 3; i ++) {
fprintf(stderr, ".");
sleep(1);
}
over = 1;
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
pthread_create(&tid, NULL, worker, NULL);
while (!over)
;
pthread_join(tid, NULL);
return 0;
}
|
首先我们不带任何编译器优化参数执行它:
xiaosuo@gentux optimization $ gcc -o volatile volatile-1.c -lpthread -O0 xiaosuo@gentux optimization $ ./volatile ...xiaosuo@gentux optimization $
|
结果正如我们所期待的那样,三秒后程序退出,下面我们用2级的编译器优化参数对程序进行编译:
xiaosuo@gentux optimization $ gcc -o volatile volatile-1.c -lpthread -O2 xiaosuo@gentux optimization $ ./volatile ... xiaosuo@gentux optimization $
|
3秒过后程序并没有象我们所期待的那样退出,以至于我不得不用Ctrl+C向其发送SIGINT信号终止其运行。两次的结果为何会如此迥异?不妨先剖析一下两次的汇编代码,因为可以很明显地知道第二次的结果是因为程序在main函数的while处进入了死循环,所以只贴出这部分代码以供对照,首先是O0的,也就是没有优化的:
call pthread_create
.L6:
cmpl $0, over
jne .L7
jmp .L6
.L7:
movl $0, 4(%esp)
|
可以看出以上汇编代码是十分忠实于原始的C代码的,基本上可以说是“直译”。那好,接下来我们看O2后的结果:
call pthread_create
cmpl $0, over
.p2align 4,,15
.L10:
je .L10
movl -4(%ebp), %eax
|
是不是有点儿不太相信自己的眼睛呢?不要看我,更不要怀疑我的操作,我可以向上帝保证我的操作是没有任何问题的。既然可以刨除人为因素,那么就只剩下机器这个死脑筋了。没错,就是这个倒霉的编译器自作聪明地把循环的条件给“优化”没了,其实也不能说完全没有判断循环条件,做了但是只有一次的判断!
呜呼哀哉!碰到了如此傲慢的愚钝编译器,你说让我如何是好?
不要急,volatile关键字可以通过明示编译器不要做优化帮忙解决这个问题。我们需要在定义变量over的时候加上volatile关键字:
static volatile int over = 0;
|
再次重复上面的操作:
xiaosuo@gentux optimization $ gcc -o volatile volatile-2.c -lpthread -O2 xiaosuo@gentux optimization $ ./volatile ...xiaosuo@gentux optimization $
|
程序运行正常,接着看看现在的汇编代码如何:
call pthread_create
.p2align 4,,15
.L10:
movl over, %eax
testl %eax, %eax
je .L10
movl -4(%ebp), %eax
|
虽然不是那么直白,但它的意译做得还是相当有水准的,极大地忠实了“原文”。
事情到这里本就应该结束了,但是我们不妨再详细推敲一下这个问题的本质:共享变量(over)的访问。如果我们对这个共享变量进行加锁保护是不是也就不会有这个问题了呢?不妨试试看,为了简单起见,这里就不再用所谓的“读写锁”,直接用互斥量:
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t mutex;
static int over = 0;
void* worker(void *args)
{
int i;
for (i = 0; i < 3; i ++) {
fprintf(stderr, ".");
sleep(1);
}
pthread_mutex_lock(&mutex);
over = 1;
pthread_mutex_unlock(&mutex);
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
pthread_mutex_init(&mutex, NULL);
pthread_create(&tid, NULL, worker, NULL);
while (1) {
pthread_mutex_lock(&mutex);
if (over)
break;
pthread_mutex_unlock(&mutex);
}
pthread_mutex_unlock(&mutex);
pthread_join(tid, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
|
xiaosuo@gentux optimization $ gcc -o volatile volatile-3.c -lpthread -O2 xiaosuo@gentux optimization $ ./volatile ...xiaosuo@gentux optimization $
|
一切都和预期的完全一样。实际上,互斥操作的存在除了禁止编译器的优化,还起到了内存屏障的作用,是访问共享数据的正规标准做法,所以对于此类情况,互斥操作优于volatile,并且互斥操作使得volatile完全没有必要,甚至有害[]。当然对于简单的数据类型(如计数值),加锁/解锁的操作未免有些过于重量级,通常如果有原子变量和其相关操作,则优先选择他们。除了以上的好处,互斥/原子操作还能隐含表明它所要操作的数据为共享数据,提高代码的可维护性。另外,pongba的一篇
文章也指明volatile对于线程间共享数据的一致性无能为力,所以不要指望volatile能给你的多线程程序带来任何好处。
volatile虽然不能在多线程编程中捞得任何好处,但是这并不表明它一无是处,在内核的驱动编程中,通常需要将硬件的寄存器映射到内核地址空间,这个时候就需要用volatile禁止编译器优化,确保每次的内存操作都是直接操纵相应的硬件寄存器而不是其它。volatile还能禁止编译器意外优化内联的汇编代码,这通常用在汇编代码操作到了不具有可见性的内存的时候。
(未完待续)
阅读(1763) | 评论(0) | 转发(0) |