标签归档:C语言

Linux OOM Killer问题

上周有一个线上程序挂了,由于早就知道服务器存在内存不足的情况,因此大概猜测问题应该出在Linux的OOM Killer上,grep了一把syslog,找到真凶,证实了我的猜测:

1
2
3
4
5
6
7
8
/var/log/messages:Nov 10 09:41:53 kernel: [8358409.054]java invoked oom-killer:
gfp_mask=0x201da, order=0, oom_adj=0, oom_score_adj=0
/var/log/messages:Nov 10 09:41:53 kernel: [8358409.054] [<ffffffff810b799b>] ?
oom_kill_process+0x49/0x271
/var/log/messages.1:Nov  9 06:29:19 kernel: [8260455.112]ntpd invoked oom-killer:
gfp_mask=0x201da, order=0, oom_adj=0, oom_score_adj=0
/var/log/messages.1:Nov  9 06:29:19 kernel: [8260455.112] [<ffffffff810b799b>] ?
oom_kill_process+0x49/0x271

Linux的内存管理存在超售现象,也就是Overcommit策略。举个栗子,当我们用C语言的malloc分配了内存之后,只要指定的内存大小不超过进程的虚拟内存大小,一般都会返回成功,只有程序在使用这块内存时,操作系统才会真正分配给它,比如调用memset()函数填充内存时。在32位操作系统下,进程的虚拟内存是4G,低3G为用户空间,高1G为内核空间,所以程序一次性可以malloc 3G以下的内存而不报错,即使当前的物理内存远远小于这个值。这样必然出现一种情况,当系统中所有进程占用的实际内存大于物理内存和swap分区之和时,Linux需要选择一个策略来处理这个问题,策略的选择就是修改Overcommit参数。

当Overcommit策略启用时,并且此时物理内存已经耗尽而又有程序需要分配内存时,Linux就会触发OOM Killer,它可以简单理解为操作系统选择一些进程杀掉,腾出内存空间。选择被杀进程的算法还是蛮讲究的,既要考虑进程重要性,比如是否root执行、是否系统进程、nice值等,又要兼顾回收效益,比如最好应该通过杀掉最少的进程来回收最多的内存。这方面,不同的内核版本也会有所区别,大概思路如2.4版源码注释所说:

1
2
3
4
5
6
7
1) we lose the minimum amount of work done
2) we recover a large amount of memory
3) we don't kill anything innocent of eating tons of memory
4) we want to kill the minimum amount of processes (one)
5) we try to kill the process the user expects us to kill, this
   algorithm has been meticulously tuned to meet the priniciple
   of least surprise ... (be careful when you change it)

注: 源码路径linux/mm/oom_kill.c,函数调用链为out_of_memory() -> oom_kill() -> select_bad_process() -> badness(),上述注释来自badness()函数。

选择被杀进程的难度,就好比社会学意义上判定一个人死刑一样,无论这个人有多少个该死的理由,但是总能找出一些这个人的存在价值。对应到应用程序,某个应用程序可能吃内存最多,但是同时它对某些业务来说最不可或缺,比如RabbitMQ,Redis,MySQL等等。于是Linux为每个进程都提供了一个可以免死的机会,就是/proc/<pid>/oom_score_adj参数。

每个进程都有一个oom_score值(位于/proc/<pid>/oom_score,总是大于等于0),这个值越大就越容易被OOM Killer杀掉,同时/proc/<pid>/oom_score_adj值(取值范围-1000~1000)可以对oom_score值进行调整,比如oom_score_adj值为-10,则会将最终的oom_score值减10。

做个实验吧,实验环境为:VPS,单核,RAM 128M, swap 256M, 32位debian6,kernel 2.6.32。

1
2
3
4
5
6
7
8
# vi oomscore.sh
#!/bin/bash
for proc in $(find /proc -maxdepth 1 -regex '/proc/[0-9]+'); do
    printf "%2d %5d %s\n" \
        "$(cat $proc/oom_score)" \
        "$(basename $proc)" \
        "$(cat $proc/cmdline | tr '\0' ' ' | head -c 50)"
done 2>/dev/null | sort -nr | head -n 10

这个oomscore.sh脚本可以显示当前oom_score排名前10的进程oom_score,pid和进程名等,借鉴来辅助观察。

首先执行oomscore.sh脚本,得到当前omm_score排名前10的进程,以及内存使用情况:

1
2
3
4
5
6
7
8
9
10
# sh oomscore.sh 
67 17976 /usr/sbin/apache2 -k start 
40   695 /usr/sbin/mysqld --basedir=/usr --datadir=/var/lib
 7 18616 sshd: root@pts/6  
.......
# free -m
             total       used       free     shared    buffers     cached
Mem:           128         75         52          0          0         26
-/+ buffers/cache:         49         78
Swap:          256         15        240

然后跑以下测试程序alloc1mps.c,每秒分配1M内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
 
#define M 1024*1024
int main(){
        int count = 1;
        while(1){
                char* mem = (char*)malloc(sizeof(char) * M);
                if(!mem){
                        break;
                }
                memset(mem, 0, M);
                printf("alloc %d M\n", count++);
                sleep(1);
        }
        printf("mem exhausted!\n");
        return 0;
}

通过sh omm_score.sh,top,free -m,dmesg等命令观察内存分配和omm_score关系,可以得到以下现象和结论:
1. 首先从分配物理内存中分配空间,等物理内存分配得差不多了,-/+ buffers/cache free趋于0时,开始从swap分配内存。
2. 当swap分区内存接近耗尽时,此时alloc1mps进程的omm_score值非常大。
3. 内存(物理内存+swap)耗尽,触发omm killer回收内存,alloc1mps进程被杀,dmesg证实这一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
# free -m
             total       used       free     shared    buffers     cached
Mem:           128        124          3          0          0          0
-/+ buffers/cache:        123          4
Swap:          256        249          6
# sh oom.sh 
791 19412 ./alloc1mps 
56 17976 /usr/sbin/apache2 -k start 
36   695 /usr/sbin/mysqld --basedir=/usr --datadir=/var/lib
......
# dmesg
[235669.855]Out of memory in UB 35782: OOM killed process 19412 (alloc1mps)
score 0 vm:318156kB, rss:119476kB, swap:196868kB

重跑测试程序,当alloc1mps开始运行后,调整其omm_score_adj参数,设为-800。

1
2
3
4
# Pid=` ps axu | grep alloc1mps | grep -v 'grep' | awk '{print $2}'`
# echo -800 > /proc/${Pid}/oom_score_adj
# cat  /proc/${Pid}/oom_score_adj
-800

继续观察,这次发现内存接近耗尽时,受oom_score_adj影响,alloc1mps进程的oom_score值低于apache2和mysqld进程,因此首先被OOM Killer杀掉的是apache2进程。apache2进程被kill后,alloc1mps继续执行,它的oom_score值也慢慢涨上来了,下一个被kill的就是它了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# free -m
             total       used       free     shared    buffers     cached
Mem:           128        122          5          0          0          0
-/+ buffers/cache:        121          6
Swap:          256        252          3
# sh oom.sh 
36   695 /usr/sbin/mysqld --basedir=/usr --datadir=/var/lib
33 21288 ./alloc1mps 
 2 22233 sh oom.sh 
......
# dmesg
[236432.377]Out of memory in UB 35782: OOM killed process 17976 (apache2)
score 0 vm:55760kB, rss:4kB, swap:22284kB
[236455.623]Out of memory in UB 35782: OOM killed process 21288 (alloc1mps)
score 0 vm:339744kB, rss:118540kB, swap:219132kB

了解了OOM Killer机制后,想要规避重要进程被kill的命运,就只能在进程运行时调整其omm_score_adj参数了,这种调整是临时的,当系统重启后会清零。所以,最根本的解决方法还是加内存。

--EOF--

奇怪的C语言数组形式

想起以前读『C陷阱与缺陷』时颠覆过我对数组认知的一个栗子: "0123456789"[n] 居然是一个合法的数组形式。

1
printf("%c", "0123456789"[0]);

以上代码运行后打印字符‘0’,依次类推,printf("%c", "0123456789"[1])打印字符‘1’,printf("%c", "0123456789"[2])打印字符‘2’……这种用法用来解决某些机器的字符集里数字不是顺序排列的问题。例如ASCII码表里字符‘0’-‘9’分别对应着编码0x30-0x39,但有些机器里不是这么按顺序排的……犹记得『K&R』也有提到某些架构的机器上不宜用c+‘0'这样方法来求c的数字表示,原因也是如此,不过具体哪些类型机器会采取这种策略就不得而知了。
  
C语言里,一个字符串常量可以用来表示一个字符数组,所以在数组名出现的地方都可以用字符串常量来替换。

--EOF--

数组和指针并不总是相同

『C专家编程』(Expert C Programming)足足花了三个章节的篇幅来讲解C语言中数组和指针的异同点,足以见得数组和指针对人们的误导能力有多强,从作者总结的冷幽默中也能察觉到一二:“在你阅读并理解前面的章节之前不要阅读这一节内容,因为它可能会使你的脑力永久退化。” = =!

数组和指针对于初学者来讲当然是不同的,很显然的是两个概念。等渐渐入门之后,会越来越发现,数组和指针就是一个东西,比如,无论将数组还是指针当作参数传给一个函数,函数内部总是将它们当作指针来处理,也就是说,编译器会将数组类型的参数退化为指针,再进行处理。此外,还能举出很多两者可以混用的例子来,甚至到最后,想举出一个例子证明数组和指针不能混用反而变得不容易起来。数组和指针最为本质的区别需从它们在内存中如何寻址说起,要对数组中元素进行存取操作,程序只需简单的将数组首地址和数组下标(对于不是1个字节长度的数据类型,编译器会对数组下标进行步长处理)相加便可得到所需的结果,数组首地址就是定义该数组时的变量名,示意图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
char a[5] = "12345";
...
char c = a[2];
                                Addr(a)-->+-----------+
                               0x12340000 | '1'(0x31) |
                                          +-----------+
                               0x12340001 | '2'(0x32) |
                                          +-----------+
                               0x12340002 | '3'(0x33) |
                                          +-----------+
                               0x12340003 | '4'(0x34) |
                                          +-----------+
                               0x12340004 | '5'(0x35) |
                                Addr(c)-->+-----------+
                               0x12340005 | '3'(0x33) |
                                          +-----------+

示例一 数组下标寻址方式
(Download ASCII Picture)

要为c赋值,首先要获得a[2]的值,a[2]的计算方法为:
1、取得a的地址,0x12340000。
2、通过数组下标计算步长,所得结果与a的地址相加,得地址0x12340002。
3、取地址0x12340002中的内容,赋值给c。

以上是数组下标引用方式的计算步骤,而指针方式的赋值则不是那么回事了,指针方式的示例图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
char a[5] = "12345";
...
char* p = a;
char c= *p;
                              Addr(a)-->+-----------+
                             0x12340000 | '1'(0x31) |<--+
                                        +-----------+   |
                             0x12340001 | '2'(0x32) |   |
                                        +-----------+   |
                             0x12340002 | '3'(0x33) |   |
                                        +-----------+   |
                             0x12340003 | '4'(0x34) |   |
                                        +-----------+   |
                             0x12340004 | '5'(0x35) |   |
                              Addr(p)-->+-----------+   |
                             0x12340005 |   0x00    |---+
                                        +-----------+
                             0x12340006 |   0x00    |
                                        +-----------+
                             0x12340007 |   0x34    |
                                        +-----------+
                             0x12340008 |   0x12    |
                              Addr(c)-->+-----------+
                             0x12340009 | '1'(0x31) |
                                        +-----------+

示例二 指针寻址方式
(Download ASCII Picture)

要为c赋值,首先要解引用p,p的解引用过程为:
1、取得p的地址,0x12340005。
2、取地址0x12340005上的内容0x12340000(由4个字节组合而成)。
3、取地址0x12340000上的内容。
如果c的赋值表达式涉及指针运算,步骤3取内容前需对地址0x12340000进行运算。

某些场合下,当程序中将数组和指针混用时,就会出现bug,以下是一种可能情况:
在文件A中定义a为数组:char a[5] = "12345";
在文件B中用extern方式声明a为指针:extern char *a;
当文件B中代码试图对a进行解引用时,根据编译器对a的理解,它是一个指针,所以用上述示例二的间接方式进行寻址,所以char c = *a语句实际上被分解为以下步骤:
1、取得a的地址,0x12340000。
2、取地址0x12340000上的内容0x34333231(由4个字节组合而成)。
3、取地址0x34333231上的内容。
错误就是这样发生了。。。根据原来的意图,我们在步骤1后面应该直接进行步骤3。同理,当在文件A中定义了指针p,而在文件B中用extern方式声明p为数组,也会发生类似的错误。这就是一个数组和指针不同的典型例子。

此外,从上述示例一和示例二所描述的寻址过程来看,不考虑编译器优化的情况下,数组方式比指针方式效率更高,因为数组方式比指针方式少一个访问内存的步骤。

--EOF--

C/C++函数默认返回值

之前有个误区,认为凡是声明了返回值类型(非void)的函数,要是在函数体中没有显式return相应类型的返回值时,编译器会报错的。今天读别人写的程序发现一个声明了返回类型是int的函数,它最后没有显式调用return返回值,但是程序流程走下来发现也没有bug,不解。遂查了下资料,解开疑惑。

C/C++不像Java中的方法,声明了非void类型的方法必须要返回一个确定类型返回值。在我使用的MSVC编译器中,一个有返回类型的函数如果函数体中没有显式调用return语句,编译器会给出一个C4716的警告(warning): warning C4716: 'func' : must return a value。那么这种情况下函数会返回什么值呢?首先的想法就是编译器帮你插入一条默认返回值。然而实际上不是这样的,C/C++函数没有默认的返回值,C/C++标准中没这种说法,因此本文题目其实起得就不够严谨。可是不写return语句确实又能返回,写一个简单的测试程序其实就能测出,没有return语句的函数会返回一个随机数,类似一个未初始化局部变量那样的数,这个数哪里来的呢?实际上,把一段程序经编译后生成汇编代码,就能看见真相。

这是一个C/C++的函数定义,它什么都不干,就返回一个整数1234。

1
2
3
4
int func()
{
    return 1234;
}

经编译后,这段函数定义会被编译成如下汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...
3:    int func()
4:    {
00401020   push        ebp
00401021   mov         ebp,esp
00401023   sub         esp,40h
00401026   push        ebx
00401027   push        esi
00401028   push        edi
00401029   lea         edi,[ebp-40h]
0040102C   mov         ecx,10h
00401031   mov         eax,0CCCCCCCCh
00401036   rep stos    dword ptr [edi]
5:        return 1234;
00401038   mov         eax,4D2h
6:    }
0040103D   pop         edi
0040103E   pop         esi
0040103F   pop         ebx
00401040   mov         esp,ebp
00401042   pop         ebp
00401043   ret
...

其他不用考虑,直接看第14行、15行。可以看到return 1234被编译成了mov eax,4D2h,由此可见,其实编译器在处理函数返回值的时候,其实是把待返回的值放在eax寄存器中,然后调用函数再从该寄存器中将值(被调函数的返回值)取出。

这点可再次通过程序验证一下,直接在被调函数体中内嵌汇编代码,为eax寄存器赋值,查看程序执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
 
int func() 
{
    _asm{
        mov eax, 1234;
    }
}
 
int main() 
{
    printf("%d\n", func()); //打印func()函数返回值1234。
    return 0;
}

回到上面问题,如果没有显示调用return返回语句,那么在调用程序中还是会去eax寄存器中取值,只是这时的eax寄存器中值是不确定的,因此会打印一串无意义的数字,于是程序就有了潜在的bug。 (衔山)

--EOF--

big endian和little endian

big endian和little endian是计算机中用于表示字节顺序的两种方式,big endian也称为大端,相应地,little endian称为小端。简单地讲,big endian就是把高字节存储在内存的低地址,little endian则是把低字节存储在内存的低地址。

假如一个32位(4个字节)的整数i,在纸面上表示为0x12345678。这里0x12所在的字节为高字节,又称MSB(Most Significant Byte),而0x78所在的字节为低字节,也称为LSB(Least Significant Byte)。对于big endian和little endian两种方式,i在内存中的存储形式是这样的:

1
2
3
4
5
                big endian     little endian
address + 0 :       0x12            0x78
address + 1 :       0x34            0x56
address + 2 :       0x56            0x34
address + 3 :       0x78            0x12

一般而言,X86平台采用的是little endian数据存储方式,大部分的PowerPC、Motorola、SPARC架构的CPU采用big endian方式存储数据。下面这个C程序可以判断出本机采用的是哪种字节序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*****************************
*用于测试机器的字节存储顺序。*
******************************/
#include <stdio.h>
 
union U
{
	int i;
	char c[sizeof(int)];
}u;
 
int main()
{
	u.i = 0x1;
	if (u.c[0] == 1){
		//如果低字节为1,表示低位存在低地址,是little endian。
		printf("little endian!\n");
	}else if (u.c[sizeof(int) - 1] == 1){
		//如果高字节为1,表示低位存到高地址,是big endian。
		printf("big endian!\n");
	}
 
	return 0;
}

当在两种不同的架构平台之间传输数据时,就要考虑对数据在big endian和little endian之间进行转化,否则就有可能造成文件或程序无法正常打开或运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
 
union U
{
	int i;
	char c[sizeof(int)];
}u;
 
/*********************************
*用于两种不同字节顺序之间的转化。*
*********************************/
int transfer(int target)
{
	int i;
 
	u.i = target;
	for (i = 0; i < sizeof(int)/2; i++)
	{
		char t = u.c[i];
		u.c[i] = u.c[sizeof(int) - i - 1];
		u.c[sizeof(int) - i - 1] = t;
	}
	return u.i;
}
 
int main()
{
	int i = 0x12345678;
 
	printf("Before: 0x%x\n", i);
	i = transfer(i);
	printf("After:  0x%x\n", i);
 
	return 0;
}

运行结果如下:

1
2
Before: 0x12345678
After:  0x78563412

另外TCP/IP协议也是采用big endian方式来传输数据的,因此进行socket等网络编程时也要注意将主机字节序转化成网络字节序,Winsock API也有提供几个函数用于字节序转化:

1
2
3
4
5
6
7
//主机字节序转网络字节序:
u_long htoul(u_long hostlong);
u_short htous(u_short hostshort);
 
//网络字节序转主机字节序:
u_long ntohl(u_long hostlong);
u_short ntohs(u_short hostshort);

如果能确定主机的字节序是little endian,那么也可以不通过API而是通过上面自己编写的transfer()函数将待传输数据进行字节序转化。

存在即合理,big endian和little endian并存除了历史原因之外,其实它们确实各有好处[1]:
big endian优势:只要查看第一个字节能快速判定一个数的正负性,不必知道这个数在内存中存储的长度。同时,高位存低地址可以使二进制转十进制非常简单。
little endian优势:汇编指令对于取数操作可以非常简单,无论什么数据类型(整型,浮点型)和长度(4字节,8字节),指令都可以采用统一的方式取数,地址偏移和字节序有着一一对应的关系。比如地址偏移0对应着字节0、地址偏移1对应着字节1……这在高精度运算中能提高效率。

总之,两者各有优势吧,不大可能统一成一种顺序。

[1] Dr. William T. Verts. An Essay on Endian Order. http://www.cs.umass.edu/~verts/cs32/endian.html. 1996-04-19. 2011-09-09 accessed.

--EOF--