口说无凭,我们先写段代码实践验证一下。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// 来源:公众号【编程珠玑】
// 作者:守望先生
// multiThread.cc
std::atomic<bool> start{false};
void threadfunc() {
while (!start) {
std::this_thread::sleep_for(std::chrono::seconds(1));
}
while (start) {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "thread func,pid:" << getpid() << std::endl;
}
}
int main() {
std::thread t1(threadfunc);
// daemon(0, 1); // 后台执行
start.store(true);
t1.join(); // 等待threadfunc运行结束
return 0;
}
编译运行:1
2
3
4
5$ g++ -o multiThread multiThread.cc -lphtread
$ ./multiThread
thread func,pid:9901
thread func,pid:9901
thread func,pid:9901
结果正常,线程不断循环打印信息。那如果启动线程后,再fork呢?即将代码中daemon的相关行的注释去掉,再编译运行。
在《如何让程序真正地后台运行?》中我们知道,daemon实际上做了进程的fork。
运行这个例子,我们会发现,程序立马退出了,没有打印我们预想的内容。
为什么会这样呢?实际上,我们在《如何使用fork创建进程》中就提到过,fork的时候会拷贝父进程的数据内容,即写时复制,但是,像启动运行的线程,是不会被“复制”过去的。也就是说,从父进程fork出来的子进程,将会是单线程的。这也就给了我们一些启示
怎么理解呢?比如说,你设计了某一个功能,你的功能是需要启动一个线程来进程工作,那么你在使用的时候,就必须要特别注意这种fork进程的场景,即需要在fork之后启动线程,才能保证线程能够正常启动并工作。
]]>假设要要计算文本test.data的第二列的数字之和:1
2
3
41 12
2 23
3 34
4 56
当然你可能会这样处理:1
awk '{s+=$2} END {print s}' test.data
很快就得到了结果。不过,本文要说的点与awk无关。我们通过另外一种方式来计算,即逐行分析处理的方式。
我们尝试第一种方式,shell实现如下:1
2
3
4
5
6
7
8
sum=0
cat test.data | while read line
do
temp_num=$(echo $line | cut -d ' ' -f 2)
sum=$(( $sum + $temp_num ))
done
echo "we get sum:$sum"
输出结果:1
we get sum:0
这是为什么!为什么得到的结果会是0呢?
这事坏就坏在脚本中的|,众所周知,这是一个管道命令,而这也就意味着,while循环的执行结果都是在一个subshell中,一旦这个subsell退出了,它里面的结果也就没有了。
既然管道命令不建议用,那么我们使用下面的方式看看:1
2
3
4
5
6
7
8
9
sum=0
for line in $(cat test.data)
do
echo "get line :$line"
temp_num=$(echo $line | cut -d ' ' -f 2)
sum=$(( $sum + $temp_num ))
done
echo "we get sum:$sum"
输出结果:1
2
3
4
5
6
7
8
9get line :1
get line :12
get line :2
get line :23
get line :3
get line :34
get line :4
get line :56
we get sum:135
从结果中看出,如果文本中存在空格或者tab等,则看似每次读取一行,实际上是遇到空格,tab或换行就停止读取了,并没有达到我们的目的。
我们预期的应该是遇到换行才停止读取,为了达到这个目的,我们可以设置这个标记,即通过设置IFS来达到目的。在上面的shell开头加上:1
IFS=$'\n'
但是修改为这样之后,在自己的系统上并没有得到我想要的效果,有知道的读者可以告知一下。
让我们再换一种方式:1
2
3
4
5
6
7
8
9
sum=0
while read line
do
echo "line $line"
temp_num=$(echo $line | cut -d ' ' -f 2)
sum=$(( $sum + $temp_num ))
done < "test.data"
echo "we get sum:$sum"
这种方式我们是能得到正确结果的。
当然,如果你要读取指定列,你还可以像下面这样做:1
2
3
4
5
6
7
sum=0
while read col1 col2
do
sum=$(( $sum + $col2 ))
done < "test.data"
echo "we get sum:$sum"
其中col1,col2就分别代表了第一列,第二列,使用的时候,可以直接使用对应列的内容。
但是,如果我们要读取的内容包括了转义字符会怎么办?例如:1
2
3
4\n 12
\n 23
\n 34
\n 56
执行结果:1
2
3
4
5
6
7
8
9line
12
line
23
line
34
line
56
we get sum:125
从结果可以看到,虽然内容能否读取到,但是内容被打印出来的时候,已经变了,\被当成转义字符处理了,如果不想让它转义处理怎么办?只需要加上-r参数即可:1
while read -r line
在逐行处理文本过程中,主要关注以下几种情况:
1 | // 来源:公众号【编程珠玑】 |
编译并查看使用到的动态库:1
2
3
4
5 gcc -o main main.c
ldd main
linux-vdso.so.1 (0x00007ffdf8fdf000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1f8535e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f1f85951000)
从ldd命令的结果我们可以看到main程序依赖了哪些动态库,并且在哪个路径。那么你有没有想过,动态库的路径是怎么找到的,查找顺序又是怎样的呢?
在此之前如果你还没有对动态库有一个基本的了解的话,建议你阅读《浅谈静态库和动态库》或其他相关资料。为了说明后面的问题,这里我们先创建一个简单的动态库,你也可以参考《手把手教你制作动态库》:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// test.c
//来源:公众号【编程珠玑】
void test()
{
printf("I am test;hello,编程珠玑\n");
test1();
}
// test.h
void test();
//test1.c
void test1()
{
printf("test1,needed by test\n");
}
// test1.h
void test1();
分别制作动态库libtest.so和libtest1.so,这在后面的示例中会用到:1
2$ gcc test1.c -fPIC -shared -o libtest1.so
$ gcc test.c -fPIC -shared -o libtest.so -L. -ltest1
这样你在当前目录下就会看到有一个libtest.so和libtest1.so文件生成了,其中litest.so依赖libtest.so
注意,由于libtest.so依赖libtest1.so,这里用-L指定了要链接的test1的路径,因此我们看到:1
2
3
4
5$ ldd libtest.so
linux-vdso.so.1 (0x00007ffd1bbca000)
libtest1.so => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9f1d0ae000)
/lib64/ld-linux-x86-64.so.2 (0x00007f9f1d6a1000)
从这里可以看出libtest是依赖libtest1库的,但是特别注意到,libtest1.so指向的是not found,这会有什么影响吗?我们后面就会看到。
我们都知道,在编译成可执行文件前,链接器链接动态库也是需要查找动态库路径的,否则怎么链接上指定的动态库呢?那么这个顺序又是怎样的呢?
首先会查找的会是编译时链接的路径。修改前面的main.c,让它调用libtest.so中的test函数:1
2
3
4
5
6
7
8// 来源:公众号【编程珠玑】
int main()
{
test(); // 调用libtest.so中的test函数
return 0;
}
编译链接:1
$ gcc -o main main.c -I ./ -L./ -ltest -ltest1
完美编译过。除此之外,如果我们把libtest.so和libtest1.so都移到/usr/lib下面,我们发现,即便不用-L也能编译过了:1
$ gcc -o main main.c -I ./ -ltest -ltest1
这里需要说明的是,我们通过-L./来指定搜索库的路径,由于libtest.so依赖libtest1.so,因此在编译链接时,也需要链接上test1。
从上面的内容可以看到,在链接时,我们通过-L参数搜索要链接的库路径,但是这个路径信息不会写到ELF文件中,因此你会通过ldd命令看到not found,而通过-rpath可以指定链接时的搜索路径,这个信息会写入到ELF文件中,最终看到的结果是,由于libtest.so依赖libtest1.so,所以其他程序依赖libtest.so时,会自动从写入ELF的rpath中搜索它依赖的其他库,因此只需要链接libtest即可,例如:
在制作库libtest1.so时,加上-rpath-link选项:1
$ gcc test.c -fPIC -shared -o libtest.so -L. -ltest1 -Wl,-rpath-link $(pwd)
在编译main的时候,就不需要特意指定链接libtest1.so1
$ gcc -o main main.c -L ./ -ltest
只需要链接libtest.so,其依赖的libtest1.so也链接进来了。
当然了,如果-L指定的路径没有呢,它还会去查找其他地方,否则依赖的系统库怎么找到呢?总结大致顺序如下:
针对具体的系统或链接器,可能有些差异,但是大致如此。
虽然前面编译成功了,但是我们运行看看,发现运行失败了。1
2$ ./main
./main: error while loading shared libraries: libtest.so: cannot open shared object file: No such file or directory
其实我们用ldd命令看一下也能看到:1
2
3
4linux-vdso.so.1 (0x00007ffcd566e000)
libtest.so => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f356d1f6000)
/lib64/ld-linux-x86-64.so.2 (0x00007f356d7e9000)
这个环境变量在介绍《性能优化-使用高效内存分配器》中的时候,也有提到,用来做测试非常方便,同样的,这种方式也最好仅仅只是用于测试,因为它的优先级非常高,并且影响全局。使用也很简单:1
2$ export LD_PRELOAD=./libtest.so
$ ./main
为了避免影响后面的验证,这里取消设置该环境变量:1
unset LD_PREALOD
上面的情况是找不到动态库,那么它首先会去rpath指定路径去查找,这需要在编译时指定:1
2
3
4
5$ gcc test.c -fPIC -shared -o libtest.so -L. -ltest1 -Wl,-rpath $(pwd)
$ gcc -o main main.c -L . -ltest -Wl,-rpath $(pwd)
$ ./main
I am test;hello,编程珠玑
test1,needed by test
也就是说,如果我们编译时指定了路径,就可以找到了,但是这些信息被写入到了ELF文件中。
另外也可以通过这个环境变量来设置要搜索库的路径。1
2
3$ gcc -o main main.c -L . -ltest
$ export LD_LIBRARY_PATH=./
$ ./main
这样运行也是没有问题的。
同样,为了避免对后面测试产生影响,取消设置该环境变量:1
unset LD_LIBRARY_PATH
我的机器上这个文件的内容如下:1
2
3
4$ cat /etc/ld.so.conf
include /etc/ld.so.conf.d/*.conf
$ ls /etc/ld.so.conf.d/
fakeroot-x86_64-linux-gnu.conf libc.conf x86_64-linux-gnu.conf
所以它实际指的是/etc/ld.so.conf.d/目录下所有conf路径包含路径,打开其中一个libc.conf,它里面包含的路径为:1
$ /usr/local/lib
既然如此,我们把前面的libtest.so复制到该目录下(可能需要sudo权限):1
2
3
4
5$ sudo cp libtest.so /usr/local/lib
$ sudo ldconfig
$ ./main
I am test;hello,编程珠玑
test1,needed by test
注意,这里拷贝完成后,需要执行ldconfig,它会更新相应的共享库,以便可执行程序能够找到。实际上,执行完成后,你确实就能在/etc/ld.so.cache文件中找到:1
$ grep -a libtest.so /etc/ld.so.cache
同样,为了影响后面测试,记得删除:1
rm /usr/local/lib/libtest.so
实际上这里是先从/etc/ld.so.cache中的路径查找,然后再从ld.so.conf的路径中查找。后面我们可以通过命令看到。
当然了,如果以上路径都没有,最终还会在lib或/usr/lib下找,为了验证,我们将库拷贝到/lib目录下1
2
3
4$ cp libtest.so /lib
$ ./main
I am test;hello,编程珠玑
test1,needed by test
同样能正常运行。
小结一下,动态库的搜索顺序如下:
以上这些查找路径你很容易来验证它们的优先级,简单的做法就是这几个位置分别放置同名不同作用的库,来看看它到底先使用哪个路径下的库,可自行尝试。
这个环境通常用来调试。例如,查看整个装载过程:1
$ LD_DEBUG=files ./main
或者查看依赖的库的查找过程:1
2
3
4$ LD_DEBUG=libs ./main
3557:find library=libtest.so [0]; searching
3557: search cache=/etc/ld.so.cache
3557: trying file=/usr/local/lib/libtest.so
另外还可以显示符号的查找过程:1
$ LD_DEBUG=symbols ./main
了解动态库的搜索路径,可以在开发中很好的帮助你定位找不到库的问题,同时LD_DEBUG环境变量也能够很好的帮助你调试,例如查看库搜索的路径,显示符号的查找过程等等。
虽然程序运行能够有多种途径获取动态库路径,但是并不是每种方式都合适,有的方式甚至完全不该用,但这超出了本文的讨论范围了。有兴趣的也可以点击阅文原文,查看《Why LD_LIBRARY_PATH is bad》
]]>还记得在《什么是强符号和弱符号》中提到的链接原则吗?
那么我们正可以利用这个原则做以下事情:
以此来实现一个类似插件的功能。通俗一点说:
其原理也非常简单,举个例子:
示例程序如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 来源:公众号【编程珠玑】
// 作者:守望先生
__attribute__((weak)) void my_print();
void test_print()
{
// 如果是强符号,说明链接了外部插件,使用外部定义
if(my_print)
{
my_print();
}
else
{
// 弱符号,走默认逻辑
printf("this is weak print\n");
}
}
int main(void)
{
test_print();
return 0;
}
上面的test_print函数是弱符号,在没有其他地方定义的情况下,也是能够正常编译运行的:1
2
3$ gcc -o main main.c
$ ./main
this is weak print
观察可执行文件:1
2$ nm main |grep my_print
w my_print
通过nm命令我们也可以知道test_print是弱符号,它前面的修饰字符是W,代表weak。
前面的示例程序已经能否工作了,如何让它能否支持插件库呢?或者说,如何让它支持外部的插件功能呢?
关于制作库(静态库或动态库制作可以参考《手把手教你制作静态库》)
这里以静态库为例:1
2
3
4
5
6// print_plugin.c
void my_print()
{
printf("this is plugin print\n");
}
制作静态库:1
2$ gcc -c print_plugin.c
$ ar -rcs libprint_plugin.a print_plugin.o
现在重新编译main程序,并使用插件库:1
2
3
4
5
6$ gcc -o main main.c -L./ -lprint_plugin
$ gcc -o main main.c -L. -Wl,--whole-archive -lprint_plugin -Wl,--no-whole-archive
$ nm main |grep my_print
000000000000067a T my_print
$ ./main
this is plugin print
需要注意的是,这里在链接插件库之前,需要加上:1
-Wl,--whole-archive
该选项会将插件库中所有符号都链接进来,若非如此,在main.c中已经有了my_print符号,将不会链接进来,而在此之后,又要将该选项恢复。最终我们可以通过nm命令看到my_print符号已经不再是W了。也就看到了最后:1
this is plugin print
的打印了。
也就实现了我们所谓插件的功能,换句话说,可以对目标程序进行功能的裁剪或者增加。
由于以下几点原因,我们可以自己做一些支持插件库的程序:
再结合前面的例子分别解释一下:
1.这一点在《什么是强符号和弱符号》一文中已经有解释说明了
2.在开始的程序中,即便没有链接插件库,程序也可以正常编译链接通过,而不会报错
3.没有链接插件库时,由于其函数地址为0,因此,我们程序内判断,if(xxx),当地址为0时,执行默认的行为语句。
可能有些朋友第一反应是,那肯定是编译不过喽:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 来源:公众号【编程珠玑】
// 作者:守望先生
// fun.c
void fun()
{
printf("编程珠玑\n");
}
// main.c
void func()
{
printf("公众号\n");
}
int main(void)
{
func();
return 0;
}
编译:1
2
3
4
5$ gcc -o main main.c fun.c
/tmp/ccKeACRk.o: In function `fun':
fun.c:(.text+0x0): multiple definition of `fun'
/tmp/cc4ezgqh.o:main.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status
可以看到这里报错了,因为fun重复定义了。
但是重复定义就会报错,会编译不过吗?不全是!
再看下面的代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 来源:公众号【编程珠玑】
// 作者:守望先生
//var.c
int num;
void change()
{
num = 1023;
}
//main.c
void change();
int num = 1024;
int main(void)
{
printf("before:num is %d\n", num);
change();
printf("after:num is %d\n", num);
return 0;
}
输出结果:1
2before:num is 1024
after:num is 1023
从结果中可以看到,虽然num被定义了两次,但是仍然可以编译通过,并且正常运行。这又是为什么呢?
在说明今天重点分享的内容之前,先简单了解一下什么是符号。在《hello程序是如何变成可执行文件的》中讲到过,ELF文件生成的最后阶段会经历链接,而链接阶段正是基于符号才能完成。每个目标文件都会有一个符号表。而链接过程正是通过符号表中的符号,将不同的目标文件“粘”在一起,形成最后的库或者可执行文件。要查看一个目标文件的符号信息也很容易:1
2
3
4
5
6
7// symbol.c
int symbol = 1024;
int func_symbol()
{
return 0;
}
编译:1
2
3
4$ gcc smbol.c #编译
$ nm symbol.o #查看符号信息
0000000000000000 T func_symbol
0000000000000000 D symbol
通过nm命令就可以查看符号信息,这里就有我们的func_symbol函数和全局变量symbol的符号。
关于nm的使用,在《几个命令了解ELF文件的秘密》也有介绍。
除了上面提到的全局符号,目标文件中还有其他符号信息,不过这不是本文关注的重点。
对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。当然也可以通过1
__attribute__((weak))
来定义一个强符号为弱符号。
通过下面的例子来看看哪些是强符号,哪些是弱符号:1
2
3
4
5
6
7
8
9
int weak; // 未初始化全局变量,弱符号
int strong = 1024; // 已初始化全局变量,强符号
__attribute__((weak)) int weak1 = 2222; // 使用标识修饰的弱符号
int main(void)
{
printf("编程珠玑\n");
return 0;
}
注意,这里的强符号与弱符号都是针对定义来说的。
对于多重定义,即标题提到的变量重名时,链接器有它的处理规则:
关于第一点,在最开始的例子中你已经见到了,最常见的情况就是你重复定义了变量或者函数等等。
而第二点也有示例,示例中,虽然定义了两个num,但是var.c中未初始化的num是弱符号,main.c中的num是强符号,这种情况下编译正常。只是最终会使用强符号的num。
再看一个第三点的例子也是类似,当其中main.c的num无初始化时,也是可以编译过的。这种情况下的误用也就罢了,如果是重复的符号,但是类型不同,问题就更大了,即var.c的内容如下:1
2
3
4
5
6//var.c
double num;
void change()
{
num = 1023;
}
这里的num变成了double,再次编译运行,你会发现意想不到的结果:1
2before:num is 1024
after:num is 0
为什么修改后是0?原因在于double类型的数据存储与int类型数据存储格式不一样(参考《对浮点数的一些理解》),且它们占用空间长度都不一样,在本文例子中,double占用8字节,而int占用4字节。
总之,这不是我们想要的结果,最终的后果可能比我们想象的要严重,要更难发现。
如非特殊需求,应该尽量避免出现全局变量同名,以免造成意料不到的结果,例如使用变量时最小范围定义,即尽可能避免全局变量,或者使用命名空间(如C++)。
当然了,强弱符号在某些时候是非常有用的,例如制作库以支持用户自定义的库,这又该怎么做呢?敬请期待下一篇。
参考书籍
1 | // 来源:公众号【 编程珠玑】 |
运行结果:1
21 + 2 = 3
1.2 + 2.3 = 3.5
从上面的结果可以看到,对于调用add函数,如果传入的是整型,则按照整型加法计算,如果是浮点数,则按照浮点数进行加法计算。也就是说,add函数没有针对特定类型(泛型)。
你同样可以使用重载实现上面的功能,但是存在大量重复代码。
很遗憾,C语言本身不支持真正意义上的泛型编程,但是却在一定程度上可以“实现泛型编程”。
_Generic是C11的关键字,通过该关键字可以有一个泛型表达式:1
_Generic((value). int:"int", float:"float",char*:"char*",default:"other type")
什么意思呢?如果value是int类型,那么表达式的值就是“int”,其他的以此类推。看起来是不是和switch语句有点类似呢?
根据这个示例,我们来实现一个功能,打印变量或常量到底是什么类型:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 来源:公众号【编程珠玑】
// 作者:守望先生
int:"int", \
char:"char", \
float:"float", \
double:"double", \
char*:"char*", \
default:"other type")
int main(void)
{
printf("1 + 2 type: %s\n",TYPE(1 + 2));
printf("1/3 type: %s\n",TYPE(1/3));
printf("2/3 type: %s\n",TYPE((float)2/3));
printf("xxx type: %s\n",TYPE("xxx"));
return 0;
}
这里为了方便使用,我们通过define关键字,将泛型表达式简化。
运行结果:1
2
3
41 + 2 type: int
1/3 type: int
2/3 type: float
xxx type: char*
可以看到通过TYPE就可以获得表达式的结果类型,这对初学者来说,可真是福音了。
既然C语言有_Generic关键字了,那么我们尝试实现开头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
26
27
28
29// 来源:公众号【编程珠玑】
// 作者:守望先生
// int类型加法
int addI(int a, int b)
{
printf("%d + %d = %d\n",a,b, a + b );
return (a + b);
}
// double类型加法
double addF(double a, double b)
{
printf("%f + %f = %f\n",a,b, a + b );
return (a + b);
}
void unsupport(int a,int b)
{
printf("unsupport type\n");
}
int:addI(a,b),\
double:addF(a,b), \
default:unsupport(a,b))
int main(void)
{
ADD(1 , 2);
ADD(1.1,2.2);
return 0;
}
观察上面的代码,我们注意到:
前面说到,_Generic关键字在C11中才有,那么C99怎么办呢?实际上,tgmath.h中提供了一些泛型类型宏,如果math.h的函数中定义了float,double和long double版本,tgmath就会提供一个泛型类型宏。效果和前面的例子一样,举个例子:1
2
3
4
5
6
7
8
9
10
11
12// 来源:公众号【编程珠玑】
// 作者:守望先生
int main(void)
{
float f = 4.0f;
long double d = 1.44;
printf("%f\n",sqrt(f)); // 实际上调用了sqrtf
printf("%Lf\n",sqrt(d)); // 实际上调用了sqrtl
return 0;
}
编译运行结果:1
22.000000
1.200000
但是不得不说,tgmath中提供的泛型宏也是有限的。
众所周知,C语言中void *指针是一种无类型指针,从这点看,也可以算是泛型指针了。而它的使用在C语言中是非常常见的,举例来说,在《函数指针》中,我们介绍了快速排序接口的使用,它的函数声明是这样的:1
2
3
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
库函数qsort实际上就是泛型排序算法了,它可以针对任何类型的数据进行排序。当然有一个前提,就是你需要按照它的协议,实现一个compar函数,用于比较大小。
像这样类似的例子,C语言中还有很多,不过相比于其他语言,如C++中的模板,这种所谓的泛型,确实有些小巫见大巫了。
C语言语法上本身基本不支持泛型编程,但是借助_Generic关键字和一些手段,可以实现泛型编程。
]]>有很多方面会造成性能问题,例如:
假设你已经通过《perf:一个命令发现性能问题》中的方法或者使用profiler分析,已经发现内存分配是性能瓶颈: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// 来源:公众号【编程珠玑】
// 作者:守望先生
// malloc.cc
void GetMemory(){
for(int i = 0;i < 100000000; i++){
void *p = malloc(1024);
if(NULL != p){
free(p);
p = NULL;
}
}
}
int main(){
std::vector<std::thread> th;
int nr_threads = 10;
for (int i = 0; i < nr_threads; ++i) {
th.push_back(std::thread(GetMemory));
}
for(auto &t : th){
t.join();
}
return 0;
}
代码非常简单,仅仅是不断分配内存而已。
编译并尝试分配十亿次:1
2
3
4
5$ g++ -g -o malloc malloc.cc -lpthread
$ time ./malloc
real0m8.677s
user0m29.409s
sys0m0.029s
分配十亿次内存,使用时间大概17s左右。另外一个终端使用perf查看情况:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22$ perf top -p `pidof malloc`
52.92% libc-2.27.so [.] cfree@GLIBC_2.2.5
31.94% libc-2.27.so [.] malloc
8.82% malloc [.] GetMemory
3.45% malloc [.] free@plt
2.51% malloc [.] malloc@plt
0.03% [kernel] [k] prepare_exit_to_usermode
0.01% [kernel] [k] psi_task_change
0.01% [kernel] [k] native_irq_return_iret
0.01% [kernel] [k] __update_load_avg_cfs_rq
0.01% [kernel] [k] __update_load_avg_se
0.01% [kernel] [k] update_curr
0.01% [kernel] [k] native_write_msr
0.01% [kernel] [k] __schedule
0.01% [kernel] [k] native_read_msr
0.01% [kernel] [k] read_tsc
0.01% [kernel] [k] interrupt_entry
0.01% [kernel] [k] update_load_avg
0.01% [kernel] [k] swapgs_restore_regs_and_return_to_usermode
0.01% [kernel] [k] reweight_entity
0.01% [kernel] [k] switch_fpu_return
0.01% [kernel] [k] perf_event_task_tick
从结果可以看到,大部分CPU耗费在了内存的申请和释放。
怎么办呢?第一要考虑的做法不是如何提升它,而是它能否避免?比如内存复用?而非反复申请?
比如使用内存池?但是要自己写一个稳定的内存池又需要耗费很大的精力了。怎么办呢?
实际上这就引出了性能优化的一种常见方法-使用性能更好的库。那么在内存分配方面,有更好的库吗?自己又不能写出一个比libc还厉害的库,就只能用用开源的库,才能维持得了写代码的生活。
目前常见的性能比较好的内存分配库有
在自己编译使用redis的时候,其实你能看到它们的身影:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# Backwards compatibility for selecting an allocator
ifeq ($(USE_TCMALLOC),yes)
MALLOC=tcmalloc
endif
ifeq ($(USE_TCMALLOC_MINIMAL),yes)
MALLOC=tcmalloc_minimal
endif
ifeq ($(USE_JEMALLOC),yes)
MALLOC=jemalloc
endif
ifeq ($(USE_JEMALLOC),no)
MALLOC=libc
endif
这里以tcmalloc为例,看一下如何使用该库替换libc中的malloc。tcmalloc使用了thread cache,小块的内存分配都可以从cache中分配。多线程分配内存的情况下,可以减少锁竞争。
你可以通过源码编译获取,github地址:https://github.com/google/tcmalloc.git
不过它需要使用bazel进行构建编译,有兴趣的可以自行尝试。
也可以直接安装:1
$ apt-get install -y libtcmalloc-minimal4
安装位置查看:1
2
3
4
5
6$ ldconfig -p | grep tcmalloc
libtcmalloc_minimal_debug.so.4 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libtcmalloc_minimal_debug.so.4
libtcmalloc_minimal.so.4 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4
libtcmalloc_debug.so.4 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libtcmalloc_debug.so.4
libtcmalloc_and_profiler.so.4 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libtcmalloc_and_profiler.so.4
libtcmalloc.so.4 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libtcmalloc.so.4
这种方式在自己测试的时候非常方便,只需要:1
$ export LD_PRELOAD=/path/to/tcmalloc.so
导入环境变量,指定库路径即可。注意这里的/path/to更换成你的tcmalloc实际的路径。运行的时候,tcmalloc库就会被首先被使用了。
这种方法就和普通库的使用没有什么区别了,链接使用就完事了。相关文章《静态库的制作与使用》
我们使用新的库,再进行10亿次的内存分配试试:1
2
3
4$ time ./malloc
real0m7.152s
user0m27.997s
sys0m0.032s
可以看到要使用的时间少了些。当然,这里的对比严格来说不是很严禁,首先这里内存分配大小比较单一,并且仅有内存分配,而没有其他处理,真正是否有效果,还是要根据实际业务程序的情况来判断。当然,整体来说,tcmalloc的效果要比libc的malloc分配内存要高效。
当你的程序中存在大量的内存分配(例如C++频繁使用string),那么可以考虑使用性能更好的内存分配库了。
]]>1 | // 来源:公众号【编程珠玑】 |
上面的代码什么意思呢?试一下几个输入输出:
示例0:1
2输入:13579
输出:13579
示例1:
1 | 输入:121abc |
示例2:1
2输入:shouwang123nb455
输出:
注意:这里输出不是123,如果想要输出123怎么办?请看后面丢弃特定字符部分。
看到这里,估计你已经看清套路了,没错,[0-9]表示scanf只读取0-9的字符,而如果遇到不在该范围内的字符,则停止,不再继续读取,就是前面我们看到的示例情况了。
scanf函数中,还有一个不常被人注意的,就是[了。它用来扫描特定的字符集。如果它以^开头,表示扫描除了字符集以外的所有字符,否则就是前面我们看到的,只扫描读取指定字符。
我们都知道,scanf在读取内容的时候,会跳过空字符,比如:
1 | char s[128] = {0}; |
假设输入为:1
bianchengzhuji
那么输出将会是:1
bianchengzhuji
注意,前面的空字符并没有读入到字符串s中,而是被跳过了。
那如果要读取空字符怎么办?很简单:1
scanf("%[^\n]",s);
这里的意思就是说,除了换行符,其他字符都读入,也就是说前面的空字符也会被读取,就达到了我们的目的了。
如果我们一开始就按回车,你会发现,s什么都没有读入,如何忽略开始的换行呢?像下面这样就可以了:
1 | // 来源:公众号【编程珠玑】 |
输入输出示例:1
2输入:[回车][回车]abc
输出:abc
输入时,按下两次回车,再输入其他字符,则最终会读取其他字符,而忽略开头的回车换行。我们知道,在scanf中,*是跳过相应的字符项,比如,跳过开头的两个数字:1
2
3
4
5
6
7
8
9
10// 来源:公众号【编程珠玑】
// 作者:守望先生
int main(void)
{
int third = 0;
scanf("%*d %*d %d",&third);
printf("%d\n",third);
return 0;
}
输入:1
111 222 333
输出:1
333
scanf会跳过前面的111和222,则会读取333,这个功能在读取文件获取特定列内容的时候很有用。同理,在前面的例子中%*[\n]即表示跳过换行,\\n则读取任意字符,直到遇到换行。
最开始的例子中,如果开头是字母,即便想读取数字,也读取不到,那么如何跳过开头的字母呢?仿照刚刚讲的:1
2
3
4
5
6
7
8
9
10// 来源:公众号【编程珠玑】
// 作者:守望先生
int main(void)
{
char a[128] = {0};
scanf("%*[a-zA-Z]%[0-9]",a);
printf("%s\n",a);
return 0;
}
这样,开头的字母就会被丢弃。
1 | char s[8] = {0}; |
输入:1
abcdefghij
输出:1
abcdefg
这样可以避免缓冲区溢出。
scanf读取内容会跳过开头的空白字符,遇到换行符或者不是目标字符时结束读取。当然,你不是没有办法,今天所分享的就是办法。当然了,很多时候,你可能会选择使用fgets,getchar之类的函数,无妨。
1 | // 来源:公众号【编程珠玑】 |
输入:1
abcd1234
输出会是什么?为什么?
]]>1 | // 来源:公众号【编程珠玑】 |
假设提供TestFun作为一个对外接口,我们编译并制作为静态库:1
2$ g++ -c api.cc -I./
$ ar -rcs libapi.a api.o
关于静态库的制作,请参考《Linux下如何制作静态库?》。
另外一个程序main.cc这么使用它:1
2
3
4
5
6
7
8
9
10// 来源:公众号编程珠玑
// 作者:守望先生
int main(){
Param param;
param.num = 10;
param.str = "24";
TestFun(param);
return 0;
}
编译链接使用:1
2$ g++ -o main main.cc -L./ -lapi -I ./
$ ./main
看起来并没有什么问题,有新的参数,可以直接在Param中增加即可,扩展性也不错。
目前来看是没有什么问题的,但是假设,还有另外一个库要使用它,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14// 来源:公众号编程珠玑
// 作者:守望先生
// use_api.h
void UseApi();
// use_api.cc
void UseApi(){
Param param;
param.num = 10;
param.str = "24";
TestFun(param);
}
也将它作为静态库:1
2$ g++ -c use_api.cc -I./
$ ar -rcs libuse_api.a use_api.o
这个时候同样主程序会用到我们的原始api,但是却使用了不同的版本,比如,新增了Param中新增了一个字段ext:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 来源:公众号【编程珠玑】
// 作者:守望先生
// api.h
struct Param{
int num;
std::string str;
std::string ext;
};
void TestFun(const Param ¶m);
// api.cc
void TestFun(const Param ¶m){
std::cout<<"num:"<<param.num<<" str:"<<param.str.c_str()<<" ext:"<<param.ext.c_str()<<std::endl;
}
重新生成静态库:1
2$ g++ -c api.cc -I./
$ ar -rcs libapi.a api.o
这个时候,通过use_api使用api接口,但是链接新的库:1
2
3
4
5
6
7// 来源:公众号编程珠玑
// 作者:守望先生
int main(){
UseApi();
return 0;
}
这个时候,再去编译链接,并运行:1
2
3$ g++ -o main main.cc -I./ -L./ -luse_api -lapi
$ ./main
Segmentation fault (core dumped)
看到没有,喜闻乐见的core dumped了,分析core还会发现,是由于访问非法地址导致的。
我们再来梳理一下这个过程:
这个时候,版本B的实现访问了新的字段,还是use_api中还是使用A版本,并没有传入新字段,因此自然会导致非法访问。
很简单,不直接暴露成员,而是提供setter和getter,而提供方式和前面提到的PIMPL方法类似。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// api.h
// 来源:公众号编程珠玑
// 作者:守望先生
class Param{
public:
void SetNum(int num);
int GetNum() const;
void SetStr(const std::string &str);
std::string GetStr() const;
void SetExt(const std::string &str);
std::string GetExt() const;
Param();
private:
class ParamImpl;
std::unique_ptr<ParamImpl> param_impl_;
};
void TestFun(const Param ¶m);
在这里头文件中只提供setter和getter,而完全不暴露成员,具体成员的设置在ParamImpl中实现: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
36// api.cc
// 来源:公众号编程珠玑
// 作者:守望先生
class Param::ParamImpl{
public:
int num;
std::string str;
std::string ext;
};
Param::Param(){
param_impl_.reset(new ParamImpl);
}
// 析构函数必须要
Param::~Param() = default;
void Param::SetNum(int num){
param_impl_->num = num;
}
int Param::GetNum() const {
return param_impl_->num;
}
void Param::SetStr(const std::string &str){
param_impl_->str = str;
}
void Param::SetExt(const std::string &ext){
param_impl_->ext = ext;
}
std::string Param::GetStr() const {
return param_impl_->str;
}
std::string Param::GetExt() const {
return param_impl_->ext;
}
void TestFun(const Param ¶m){
std::cout<<"num:"<<param.GetNum()<<" str:"<<param.GetStr().c_str()<<"ext:"<<param.GetExt().c_str()<<std::endl;
}
通过上面的方式,不会直接暴露成员函数,而是提供接口设置或者获取,而在实现中,即便出现新的版本增加了接口,最多也只是获取到默认值,而不会导致程序崩溃。
本文和之前的文章实现方法是一样的,这样不暴露成员的做法,更大程度避免了链接库不一致导致的问题,你学会了吗?
源码地址:源代码
]]>有时候我们需要提供对外的API,通常会以头文件的形式提供。举个简单的例子:
提供一个从某个指定数开始打印的接口,
头文件内容如下:1
2
3
4
5
6
7
8
9
10
11
12
13//来源:公众号编程珠玑
//作者:守望先生
//test_api.h
class TestApi{
public:
TestApi(int s):start(s){}
void TestPrint(int num);
private:
int start_ = 0;
};
实现文件如下:1
2
3
4
5
6
7
8
9
10//来源:公众号编程珠玑
//作者:守望先生
//test_api.cc
TestApi::TestPrint(int num){
for(int i = start_; i < num; i++){
std::cout<< i <<std::endl;
}
}
类TestApi中有一个私有变量start_,头文件中是可以看到的。
1 |
|
从前面的内容来看, 一切都还正常,但是有什么问题呢?
第一点可以很明显的看出来,其中的私有变量start_能否在头文件中看到,如果实现越来越复杂,这里可能也会出现更多的私有变量。有人可能会问,私有变量外部也不能访问,暴露又何妨?
不过你只是提供几个接口,给别人看到这么多信息干啥呢?这样就会导致实现和接口耦合在了一起。
另外一方面,如果有另外一个库使用了这个库,而你的这个库实现变了,头文件就会变,而头文件一旦变动,就需要所有使用了这个库的程序都要重新编译!
这个代价是巨大的。
所以,我们应该尽可能地保证头文件不变动,或者说,尽可能隐藏实现,隐藏私有变量。
Pointer to implementation,由指针指向实现,而不过多暴露细节。废话不多说,上代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16//来源:公众号编程珠玑
//作者:守望先生
//test_api.h
class TestApi{
public:
TestApi(int s);
~TestApi();
void TestPrint(int num);
private:
class TestImpl;
std::unique_ptr<TestImpl> test_impl_;
};
从这个头文件中,我们可以看到:
我们再来看下具体的实现: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//来源:公众号编程珠玑
//作者:守望先生
//test_api.cc
class TestApi::TestImpl{
public:
void TestPrint(int num);
TestImpl(int s):start_(s){}
TestImpl() = default;
~TestImpl() = default;
private:
int start_;
};
void TestApi::TestImpl::TestPrint(int num){
for(int i = start_; i < num; i++){
std::cout<< i <<std::endl;
}
}
TestApi::TestApi(int s){
test_impl_.reset(new TestImpl(s));
}
void TestApi::TestPrint(int num){
test_impl_->TestPrint(num);
}
//注意,析构函数需要
TestApi::~TestApi() = default;
从实现中看到,TestApi中的TestPrint调用了TestImpl中的TestPrint实现,而所有的具体实现细节和私有变量都在TestImpl中,即便实现变更了,其他库不需要重新编译,而仅仅是在生成可执行文件时重新链接。
从例子中,我们可以看到PIMPL模式中有以下优点:
当然了,由于实现在另外一个类中,所以会多一次调用,会有性能的损耗,但是这点几乎可以忽略。
代码地址:源代码
]]>先来看一段代码:
1 | // 来源:公众号编程珠玑 |
编译链接:
1 | $ g++ -o main main.cc const.cc |
我们发现出现了链接问题,说const_int没有定义的引用,但我们确实在const.cc文件中定义了。
我们去掉const修饰符再编译一次,发现是可以的。从上面这个编译问题,就引出今天要讲的内容了。至于为什么会编译不过,最后再做解释。
当然你会发现,按照C代码去编译,是可以编译出来的。后面再解释。
我们都知道,C/C++代码的编译通常经过预编译,汇编,编译,链接(参考hello程序是怎么生成的)通常会有变量会有三种链接属性:外部链接,内部链接或无链接。
具有函数作用域,块作用域或者函数原型作用域的变量都是无链接变量;具有文件作用域的变量可以是内部链接也可以是外部链接。而外部链接变量可以在多个文件中使用,内部链接变量只能在一个编译单元中使用(一个源代码文件和它包含的头文件)。
关于作用域,也可以参考《全局变量,静态全局变量,局部变量,静态局部变量》。
说了这么多,举个具体的例子:1
2
3
4
5
6
7
8
9
10
11// 来源:公众号【编程珠玑】
// 作者:守望先生
int external_link = 10; // 文件作用域,外部链接
static internal_link = 20; // 文件作用域,内部链接
int main()
{
int no_link = 30; // 无链接
printf("%d %d %d \n",external_link,internal_link,no_link);
return 0;
}
这里无链接变量还是比较好区分的,只要不是文件作用域的变量,基本是无链接属性。而文件作用域变量是内部链接还是外部链接呢?只要看前面是否有static修饰即可。当然对于C++,还要看是否有const修饰,后面我们再说。
举个例子,在前面的代码中,我们按照C代码进行编译:1
2
3$ gcc -c const.c
$ nm const.o |grep const_int
0000000000000000 R const_int
nm命令在《linux常用命令-开发调试篇》中略有介绍,它可以用来查看ELF文件的符号信息。
从这里的结果可以看到const_int前面是R修饰的,
R:该符号位于只读数据区,READONLY的含义
而该字母大写,其实也是表示它具有外部链接属性。
再看看按照C++代码编译:1
2
3$ g++ -c const.c
$ nm const.o |grep const_int
0000000000000000 r _ZL9const_int
可以看到,它的修饰符也是r,但是是小写的r,小写字母表示该变量具有内部链接属性。
nm命令非常实用,但本文不是重点。
说到const关键字,在《const关键字到底该怎么用》和《C++中的const与C中的const有何差别?》中已经分析过了,这里简单说一下,被const关键字修饰的变量,表明它是只读的,不希望被修改。
extern关键字可以引用外部的定义,想必很多朋友已经很熟悉了,举个例子,如果把最开始的例子中的const关键字去掉,main.cc中的extern的意思,就是说有一个const_int变量,但是它在别的地方定义的,因此这里extern修饰一下,这样在链接阶段,它就会去其他的编译单元中找到它的定义,并链接。
当然,还有一个不太被关注的作用是,在C++中,它可以改变const变量的链接属性:
是的,在C++中,它改变了const_int的链接属性。我们可以修改const.c的内容如下:1
extern const int const_int = 10;
然后再查看一下:1
2 $ nm const.o |grep const_int
0000000000000000 R const_int
发现没有,它前面的修饰变成大写的R了,所以这个时候,你再编译,就能编译过,而不会报错了,对于C,它本来就是外部链接属性,所以根本不会报错。
extern还有另外一个用法:
《C++是如何调用C接口的》?
所以,链接报错的通常问题就是找不到定义,原因无非就是:
由于在C++中,被const修饰的变量默认为内部链接属性,因为编译会找不到定义。
本文从一个编译问题,引出了很多内容,包括:
当发现磁盘占用比较多的时候,可以通过下面的命令,查看各个挂载路径的占用情况:
1 | df -h |
当然我这里并没有哪个挂载路径的磁盘占用率比较高,这里假设home占用比较高,然后可以通过:
1 | $ cd /home |
这样可以逐层知道哪些目录有了不该有的大文件。
当然你也可以使用find直接找出大文件,比如查找当前目录下大于800M的文件:1
$ find . -type f -size +800M
find的用法可以参考《find命令高级用法》。
如果找到了该文件,并且确认是无用文件,那么就可以删除了。
但是如果仍然有程序打开了该文件,那么即便你删除了文件,其占用的磁盘空间也并不会释放,因为仍然它的”文件引用”不是0,文件并不会被删除。
在《rm删除文件空间就释放了吗?》一文中,有更加详细的解释。
所以你需要看一下,是否还有程序打开该文件,举个例子:1
2
3$ lsof config.json
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
less 6750 shouwang 4r REG 8,10 233 3411160 config.json
从上面的结果,可以看到,是less程序打开了config.json文件,并且它的进程id是6750。
找到进程之后,根据实际情况决定是否需要停止程序,然后删除大文件。
现实常常可能不如意,比如虽然可以通过df命令看到某些挂载路径磁盘占用率比较高,但是始终找不到大文件,那么你就要考虑,是不是大文件看似被删除了,但是还有程序打开。要找到这样的文件,其实也很简单,前面已经介绍过了:1
lsof |grep deleted
lsof能看到被打开的文件,而如果文件被删除了(比如使用rm命令),但是仍然有程序打开,则会是deleted状态,举个例子:1
2$ touch test.txt
$ less test.txt
创建一个文件test.txt,并随意输入一些内容,然后使用less命令打,随后在另一个终端,删除该文件:1
2
3$ rm test.txt
$ lsof |grep test.txt |grep deleted
less 6989 shouwang 4r REG 8,10 134 3541262 /home/shouwang/workspaces/shell/testdeleted/test.txt (deleted)
可以看到打开该文件的进程id为6989,我们看一下这个程序打开的文件:1
2
3
4
5
6
7
8
9$ ls -al /proc/6989/fd
dr-x------ 2 shouwang shouwang 0 10月 6 10:57 .
dr-xr-xr-x 9 shouwang shouwang 0 10月 6 10:56 ..
lrwx------ 1 shouwang shouwang 64 10月 6 10:57 0 -> /dev/pts/1
lrwx------ 1 shouwang shouwang 64 10月 6 10:57 1 -> /dev/pts/1
lrwx------ 1 shouwang shouwang 64 10月 6 10:57 2 -> /dev/pts/1
lr-x------ 1 shouwang shouwang 64 10月 6 10:57 3 -> /dev/tty
lr-x------ 1 shouwang shouwang 64 10月 6 10:57 4 -> '/home/shouwang/workspaces/shell/testdeleted/test.txt (deleted)'
$ du -h
(关于proc虚拟文件系统,可以参考《Linux中不可错过的信息宝库》)。从上面也可以看到,文件描述符4的文件为test.txt,但是deleted状态。
停止这个进程,你会发现所占用的磁盘空间会被释放。
通常在终端启动一个程序后,文件描述符0,1,2通常对应标准输入,标准输出,标准错误。从前面的例子中也能窥见一二,它打开的是/dev/pts/1,其实就是当前终端。更多信息可以参考《如何理解Linux shell中“2>&1”》。
回到开始的问题,之前例子中daemonize的参考实现如下: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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72//来源:公众号【编程珠玑】
//https://www.yanbinghu.com
/*实现仅供参考,可根据实际情况调整*/
int daemonize()
{
/*清除文件权限掩码*/
umask(0);
/*父进程退出*/
pid_t pid;
if((pid=fork()) < 0)
{
/*for 出错*/
perror("fork error");
return -1;
}
else if(0 != pid)/*父进程*/
{
printf("father exit\n");
exit(0);
}
/*子进程,成为组长进程,并且摆脱终端*/
setsid();
/*修改工作目录*/
if(chdir("/") < 0)
{
perror("change dir failed");
return -1;
}
struct rlimit rl;
/*先获取文件描述符最大值*/
if(getrlimit(RLIMIT_NOFILE,&rl) < 0)
{
perror("get file decription failed");
return -1;
}
/*如果无限制,则设置为1024*/
if(rl.rlim_max == RLIM_INFINITY)
rl.rlim_max = 1024;
/*为了使得终端有输出,保留了文件描述符0,1,2;实际上父进程可能没有打开2以上的文件描述符*/
int i;
for(i = 3;i < rl.rlim_max;i++)
close(i);
return 0;
}
int main(void)
{
if(0 == daemonize())
{
while(1)
{
printf("daemonize ok\n");
sleep(2);
}
}
else
{
printf("daemonize failed\n");
sleep(1);
}
return 0;
}
这里注意到,daemonize函数最后关闭了2以上的文件描述符。
在其中一个终端运行上面的例子:1
2
3
4
5
6
7
8$ gcc -o daemon daemon.c #编译
$ ./daemon #运行
$ ls -al /proc/`pidof daemon`/fd #查看打开的文件
dr-x------ 2 shouwang shouwang 0 10月 6 11:26 .
dr-xr-xr-x 9 shouwang shouwang 0 10月 6 11:26 ..
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 0 -> /dev/pts/4
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 1 -> /dev/pts/4
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 2 -> /dev/pts/4
可以看到0,1,2打开的是程序所在的终端,这时关闭该终端,在另外一个终端执行:1
2
3
4$ ls -al /proc/`pidof daemon`/fd
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 0 -> '/dev/pts/4 (deleted)'
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 1 -> '/dev/pts/4 (deleted)'
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 2 -> '/dev/pts/4 (deleted)'
发现0,1,2都是deleted状态了,因为关闭前面启动程序的终端后,也相当于删除了它标准输入输出和标准错误指向的文件。
实际上,到这里,都没有任何问题,程序中的printf打印最多无法打印出来而已。
但是,如果程序不是终端启动的呢?或者说没有终端的环境,比如crontab启动,at命令启动:1
$ at now <<< “./daemon"
at命令表示当前时间执行daemon程序。
再看看它打开的文件:1
2
3
4$ ls -l /proc/`pidof daemon`/fd
lr-x------ 1 shouwang shouwang 64 10月 6 11:42 0 -> '/var/spool/cron/atjobs/a00001019765fe (deleted)'
lrwx------ 1 shouwang shouwang 64 10月 6 11:42 1 -> '/var/spool/cron/atspool/a00001019765fe (deleted)'
lrwx------ 1 shouwang shouwang 64 10月 6 11:42 2 -> '/var/spool/cron/atspool/a00001019765fe (deleted)'
看见没有,你会发现它打开了一些奇怪的文件。
很明显,我们自己写的程序中并没有打开这样的文件,但是从文件名可以推断,它看能是cron程序打开的。那么怎么会变成daemon程序打开了呢?
这要从fork说起,之前在《如何创建子进程?》中说到过,fork出来的子进程会继承父进程的文件描述符,我们的daemon实现已经将2以上的描述符关闭了,但是并没有关闭0,1,2,而由于daemon程序自己实际上没有打开任何文件,0,1,2是空着的,实际上就变成了打开的是父进程曾经打开的文件。
但是由于printf持续向标准输出打印信息,即不断向描述符1打开的文件写入内容,而该文件又是deleted状态,最终可能会导致磁盘空间占用不断增大,但是又找不到实际的大文件。
为了验证我们的想法,可以看下前面的文件内容到底是什么:1
2
3
4
5
6$ tail -5 /proc/`pidof daemon`/fd/1
daemonize ok
daemonize ok
daemonize ok
daemonize ok
daemonize ok
看到了吗,这既是我们程序的打印!竟然打印到一个毫无相关的文件中了。
从上面的例子可以看到,要想实现一个线上可用的daemon程序,还必须重定向标准输入,标准输出和标准错误,比例:1
2
3
4/* redirect stdin, stdout, and stderr to /dev/null */
open("/dev/null", O_RDONLY);
open("/dev/null", O_RDWR);
open("/dev/null", O_RDWR);
如果我们不关心这些输入输出,则重定向到/dev/null,相当于丢弃该内容,关于/dev/null,这里有更多的介绍《linux下这些特殊的文件》。
是否要重定向标准输入输出,完全取决于你的实际应用场景,比如某些情况你可能就是需要将标准输出指向父进程的文件,则可以不需要重定向。当然了,至于实现,更推荐的做法是调用daemon函数:1
2
int daemon(int nochdir, int noclose);
本文主要涉及以下内容:
为了取得程序的一丁点性能提升而大幅度增加技术的复杂性和晦涩性能,这个买卖做不得,这不仅仅是因为复杂的代码容器滋生bug,也因为他会使日后的阅读和维护工作要更加艰难。《Unix编程艺术》
也许是想要支持更高的吞吐量,想要更小的延迟,或者提高资源的利用率等,这些都是性能优化的目标之一。不过需要提醒的是,不要过早的进行性能优化。如果当前并没有任何性能问题,又何必耗费这个精力呢?当前一些有助于提高性能的编码习惯还是可以时刻保持的。
全面的性能优化不是一件简单的事情。本系列文章不在于介绍性能优化原理或者特定的算法优化。旨在分享一些实践中常用到的技巧,同时也主要关注CPU方面。
解决性能问题的第一步是发现性能问题。如何快速发现性能问题呢?对于本文来说,如何发现那些使CPU不停地瞎忙的代码呢?为什么这里是说让CPU瞎忙的代码?
举个例子,完成某个事情,你可能只需要一个CPU时间片,但是由于代码不够好,使得仍然需要多个CPU时间片。导致CPU非常忙碌,而无法继续提高它的效率。
这个命令相信大家都用过,可以实时看到进程的一些状态。它的使用方法有很多文章不厌其烦地对其进行了介绍,本文不打算进行介绍。我们可以通过top命令看到某个进程占用的CPU,但是CPU占用高并不代表它有性能问题,也有可能是CPU正在有效地高速运转,并没有占着茅坑不拉屎。
想必我们都听过八二法则,同样的,80%的性能问题集中于20%的代码。因此我们只要找到这20%的部分代码,就可以有效地解决一些性能问题。
本文使用perf命令,它很强大,支持的参数也非常多,不过没关系,本文也没打算全部介绍。
系统中可能没有perf命令,ubuntu可以使用如下方法安装:1
sudo apt install linux-tools-common
直接来看示例吧。例子很简单,只是将字符串的字母转为大写罢了。当然了,很多人可能一眼就看出了哪里有性能问题,不过没关系,这个例子只是为了说明perf的应用。
1 | //来源:公众号【编程珠玑】 |
编译成可执行程序并运行:1
2$ gcc -o toUpper toUpper.c
$ ./toUpper
这个时候我们用top查看结果发现toUpper程序占用CPU 100%:1
2
3$ top -p `pidof toUpper`
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
24456 root 20 0 5248 2044 952 R 100.0 0.0 0:07.13 toUpper
打开另外一个终端,执行命令:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15$ perf top -p `pidof toUpper`
Samples: 1K of event 'cycles:ppp', Event count (approx.): 657599945
Overhead Shared Object Symbol
99.13% libc-2.23.so [.] strlen
0.19% [kernel] [k] perf_event_task_tick
0.11% [kernel] [k] prepare_exit_to_usermode
0.10% libc-2.23.so [.] toupper
0.09% [kernel] [k] rcu_check_callbacks
0.09% [kernel] [k] reweight_entity
0.09% [kernel] [k] task_tick_fair
0.09% [kernel] [k] native_write_msr
0.09% [kernel] [k] trigger_load_balance
0.00% [kernel] [k] native_apic_mem_write
0.00% [kernel] [k] __perf_event_enable
0.00% [kernel] [k] intel_bts_enable_local
其中pidof命令用于获取指定程序名的进程ID。
看到结果了吗?可以很清楚地看到,strlen函数占用了整个程序99%的CPU,那这个CPU的占用是否可以优化掉呢?我们现在都清楚,显然是可以的,在对每一个字符串进行大写转换时,都进行了字符串长度的计算,显然是没有必要,可以拿到循环之外的。
同时我们也关注到,这里面有很多符号可能完全没见过,不知道什么含义了,比例如reweight_entity,不过我们知道它前面有着kernel字样,因此也就明白,这是内核干的事情,仅此而已。
这里实时查看的方法,当然你也可以保存信息进行查看。1
$ perf record -e cycles -p `pidof toUpper` -g -a
执行上面的命令一段时间,用于采集相关性能和符号信息,随后ctrl+c中止。默认当前目录下生成perf.data,不过这里面的数据不易阅读,因此执行:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17$ perf report
+ 100.00% 0.00% toUpper [unknown] [k] 0x03ee258d4c544155
+ 100.00% 0.00% toUpper libc-2.23.so [.] __libc_start_main
+ 99.72% 99.34% toUpper libc-2.23.so [.] strlen
0.21% 0.02% toUpper [kernel.kallsyms] [k] apic_timer_interrupt
0.19% 0.00% toUpper [kernel.kallsyms] [k] smp_apic_timer_interrupt
0.16% 0.00% toUpper [kernel.kallsyms] [k] ret_from_intr
0.16% 0.00% toUpper [kernel.kallsyms] [k] hrtimer_interrupt
0.16% 0.00% toUpper [kernel.kallsyms] [k] do_IRQ
0.15% 0.15% toUpper libc-2.23.so [.] toupper
0.15% 0.00% toUpper [kernel.kallsyms] [k] handle_irq
0.15% 0.00% toUpper [kernel.kallsyms] [k] handle_edge_irq
0.15% 0.00% toUpper [kernel.kallsyms] [k] handle_irq_event
0.15% 0.00% toUpper [kernel.kallsyms] [k] handle_irq_event_percpu
0.14% 0.00% toUpper [kernel.kallsyms] [k] __handle_irq_event_percpu
0.14% 0.01% toUpper [kernel.kallsyms] [k] __hrtimer_run_queues
0.13% 0.00% toUpper [kernel.kallsyms] [k] _rtl_pci_interrupt
其中-g参数为了保存调用调用链,-a表示保存所有CPU信息。
因此就可以看到采样信息了,怎么样是不是很明显,其中的+部分还可以展开,看到调用链。
例如展开的部分信息如下:1
2
3- 100.00% 0.00% toUpper libc-2.23.so [.] __libc_start_main
- __libc_start_main
99.72% strlen
当然了,实际上你也可以将结果重定向到另外一个文件,便于查看: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$ perf report > result
$ more result
# Event count (approx.): 23881569776
#
# Children Self Command Shared Object Symbol
# ........ ........ ....... ................. ..............................
...................
#
100.00% 0.00% toUpper [unknown] [k] 0x03ee258d4c544155
|
---0x3ee258d4c544155
__libc_start_main
|
--99.72%--strlen
100.00% 0.00% toUpper libc-2.23.so [.] __libc_start_main
|
---__libc_start_main
|
--99.72%--strlen
99.72% 99.34% toUpper libc-2.23.so [.] strlen
|
--99.34%--0x3ee258d4c544155
这样看也是非常清晰的。
不过不要高兴地太早,并不是所有情况都能清晰的看到具体问题在哪里的。
至于本文例子的性能问题怎么解决,相信你已经很清楚了,只需要把strlen提到循环外即可,这里不再赘述。
本文的例子过于简单粗暴,但是足够说明perf的使用,快速发现程序中占用CPU较高的部分,至于该部分能否被优化,是否正常就需要进一步分析了。不过别急,后续将会分享一些常见的可优化的性能点。
]]>作为一个轻度使用者,应读者要求,斗胆介绍一下makefile,不过与普通的makfile教程不同的是,本文准备从另外一个角度来介绍。如有不妥之处,欢迎指出。
在Linux下,对于下面这个简单的程序1
2
3
4
5
6
7
8
9
10
11
12//来源:公众号【编程珠玑】
//main.c
int main()
{
int a = 10;
int b = 4;
int c = pow(a,b);
printf("10^4 = %d",c);
return 0;
}
我们通常使用gcc就可以编译得到想要的程序了:1
$ gcc -o main main.c -lm
(如果不理解为什么要加-lm,请参考《一个奇怪的链接问题》)。
对于单个文件的简单程序,一条命令就可以直接搞定了(编译+连接),但是如果是一个复杂的工程,可能有成千上万个文件,然后需要链接大量的动态或静态库。试想一下,你还想一条一条命令执行吗?懒惰的基因是刻在程序员骨子里的。
因此你可能会想,那我写个脚本好了。嗯,听起来好多了。
文件多就多,你告诉我要编译哪里的文件,我遍历一下就好了,你再告诉我要链接哪些库,我一一帮你链接上就好了。
然而到这里又会想,既然编译链接都是这么类似的过程,能不能给它们写一些通用的规则,搞得这么复杂干嘛?然后按照规则去执行就好了。
而makefile就是这样的一个规则文件,make是规则的解释执行者。可以类比shell脚本和bash解释程序的关系。
所以,makefile并不仅仅用于编译链接,只不过它非常适合用于编译链接。
它最重要的规则语法如下:1
2<target> : <prerequisites>
[tab] <commands>
咋一看,就这么个玩意?但是什么意思?
关键内容就这些,但是要细讲会有很多内容,本文仅举个简单的例子。假设要将前面的main.c复制名为pow.c的文件。
那么我们可以得到:
因此我们得到我们的makefile文件内容如下:1
2
3
4pow.c:main.c
cp main.c pow.c
clean:
rm pow.c
假设当前目录下没有main.c文件,然后在当前目录下执行:1
2$ make pow.c
make: *** No rule to make target `main.c', needed by `pow.c'. Stop.
我们发现会报错,因为你要依赖的文件找不到,而且也没有其他规则能够生成它。
现在把main.c放在当前目录下后继续执行:1
2$ make
cp main.c pow.c
看见没有,执行完make命令之后,我们的pow.c文件终于有了。
而执行下面的命令后:1
2$ make clean
rm pow.c
你就会发现pow.c被删除了。
如果当前目录有clean文件会发生什么?1
2$ make clean
make: `clean' is up to date.
至于原因,后面会讲到。
这里注意,如果你的makefile文件的文件名不是makefile,那么就需要指定具体名字,例如假设前面的文件名为test.txt:1
$ make -f test.txt
以上例子介绍了makefile使用的基本流程,生成目标,清除目标。然而实际上这里面的门道还有很多,例如伪目标,自动推导,隐晦规则,变量定义。本文作为认识性的文章暂时不具体介绍。
总结来说就是,给规则,按照规则生成目标。
网上有很多教程介绍如何编写makefile的,很多也非常不错。不过本文换个角度来说。
既然我们要学makefile,那么就需要知道构建C/C++项目的时候,它应该做什么?然后再去学习如何编写makefile。
实际上它主要做的事情也很清楚,那就是编译和链接。这个在《helo程序是如何编程可执行文件的》中已经有所介绍,还不了解的朋友可以简单了解一下。那么放到makefile中具体要做什么呢?
所以问题就变成了,如何利用makefile的语法规则快速的将成千上万的.c编译成.o,并且正确链接到需要的库。
而如果用makefile应该怎么写才能得到我们的程序呢?为了帮助说明,我们把前面的编译命令拆分为两条:1
2$ gcc -g -Wall -c main.c -o main.o
$ gcc -o main main.o -lm
由于我们使用的是gcc编译器(套件),因此可以像下面这样写:1
CC=gcc
为了扩展性考虑,常常将编译器定义为某个变量,后面使用的时候就会方便很多。
比如我们要设置-g选项用来调试,设置-Wall选项来输出更多警告信息。1
CFLAGS=-g -Wall
我们这里只用到了libm.so库1
LIBS=-lm
我们的目标文件是main.o依赖main.c,该规则应该是这样的:1
2
3OBJ=main.o
$(OBJ):main.c
$(CC) $(CFLAGS) -c main.c -o $(OBJ)
这样就得到了我们的目标文件。
接下来就需要将目标文件和库文件链接在一起了。1
2
3TARGET=main
$(target):main.o
$(CC) $(CFLAGS) -o $(TARGET) $(OBJ) $(LIBS)
而为了使用make clean,即通常用于清除这些中间文件,因此需要加一个伪目标clean:1
2
3.PHONY:clean
clean:
rm $(OBJ) $(TARGET)
伪目标的意思是,它不是一个真正的要生成的目标文件,.PHONY:clean说明了clean是一个伪目标。在这种情况下,即使当前目录下有clean文件,它也仍然会执行后面的指令。
否则如果当前目录下有clean文件,将不会执行rm动作,而认为目标文件已经是最新的了。
1 | CC=gcc |
可以看到,makefile文件中有三个目标,分别是main.o,main和clean,其中clean是一个伪目标。
注意,由于第一个目标是main.o,因此你单单执行make的时候,它只是会生成main.o而已,如果你再执行一次会发现它提示你说main.o已经是最新的了:1
2
3
4$ make
gcc -g -Wall -c main.c -o main.o
$ make
make: `main.o' is up to date.
为了得到main,我们执行:1
2
3
4
5$ make main
gcc -g -Wall -c main.c -o main.o
gcc -g -Wall -o main main.o -lm
$ ls
main main.c main.o makefile
当然你也可以调整目标顺序。这里的目标文件main依赖的是main.o,它开始会去找main.o,发现这个文件也没有,就会看是不是有规则会生成main.o,欸,你还别说,真有。main.o又依赖main.c,也有,最终按照规则就会先生成main.o,然后生成mian。
如果要清除这些目标文件,那么可以执行make clean:1
2
3
4$ make clean
rm main.o main
$ ls
main.c makefile
本文主要介绍了两部分内容。
它是一个规则文件,里面按照某种语法写好了,然后使用make来解释执行,就像shell脚本要用bash解释运行一样。通常会用makefile来构建C/C++项目。
makefile主要做下面的事情(以C程序为例)
其中最关键的事情就是编译链接,即想办法把.c变成.o(可重定位目标文件);.o+.so(动态库)+.a(静态库)变成可执行文件。
对于文本提到的例子,看起来实在有些笨拙,一条指令搞定,却要写这么多行的makefile,但是它却指出了通常编写makefile的基本思路。
对于一个复杂的项目而言,makefile还有很多东西可介绍,例如如何设置变量,如何交叉编译,如何多个目录编译,如何自动推导,如何分支选择等等。这些都是后话了。
]]>还是拿《多线程排序》中的程序举例,下面是各个线程数量的排序结果:
线程数 | 时间/s |
---|---|
1 | 2.393644 |
2 | 1.367392 |
3 | 1.386448 |
4 | 1.036919 |
5 | 1.097992 |
6 | 1.218000 |
7 | 1.184615 |
8 | 1.176258 |
以上结果可能不准确,但是体现了一些变化趋势,即并不是线程数量越多越快,也不是单线程最快,而是线程数为4的时候最快。
为什么呢?
原因在于我的机器只有4个逻辑CPU,因此4是最合适的。为了不解释太多术语,简单解释一下。一个CPU就像一条流水线,会执行一系列指令,当你很多指定拆成4份(4线程)的时候,它是正好最合适的,少的时候,有一个闲着;而多了,就会存在抢占的情况。举个简单的例子,假设有4个水管可以出水,你现在去接水,那么你在每个水管下放一个桶去接水,自然要比只在一个水管下去接水要快的,但是如果你的水桶数量多于水管数,为了每个水桶都要有水,你在这个过程中就需要去切换水桶,每个水桶换一下,才能都接得上,而换的这个过程就像线程的上下文切换带来的开销。
因此,并不是线程越多越快,最合适的才最快。
说到这你可能更会奇怪了,为什么单线程有时候反而会更快呢?还是拿接水为例,假设虽然有4个水管,但是你只有一个桶,因此你一个人从这个水管里一直接水是最快的,而如果你拿两个桶,这个接一点,又换一下,那个接一点,又换一下,中间显然有中断,相同时间内单个桶接的比较多;这就是单核CPU妄图使用多线程提高效率或者每个线程都需要竞争同一把锁而实际可能会导致更慢的缘故。
举个绑核的例子:1
2
3 taskset -c 1 taskset -c 1 ./multiThread 4
thread num:4
time 2.378558
我使用taskset将程序绑定在一个CPU上运行,可以看其时间足足是不绑核的时候的两倍有余。
什么叫都需要竞争呢?举个极端的例子,我们修改前面的工作线程代码如下:1
2
3
4
5
6
7
8
9
10
11
12/*比较线程,采用快速排序*/
void * workThread(void *arg)
{
pthread_mutex_lock(&mutex);
SortInfo *sortInfo = (SortInfo*)arg;
long idx = sortInfo->startIdx;
long num = sortInfo->num;
qsort(&nums[idx],num,sizeof(long),compare);
pthread_mutex_unlock(&mutex);
pthread_barrier_wait(&b);
return ((void*)0);
}
这里的例子比较极端,在排序的时候都给它们加上了锁(关于锁,后面会有文章进行更加详细的介绍。),即哪个线程拿到了锁,就可以继续工作,没有拿到的继续等待。使用完成后再释放。
在这样的情况下,看看4线程还有效果吗?1
2
3 ./multiThread 4
thread num:4
time 2.480588
是最快的时候两倍多的时间!而且还比单个线程的时候要慢!!!
而另外一种情况,比如说从队列中取出数据,然后进行耗时处理,那么对取出数据的操作进行加锁是可行的,多线程的情况仍然能提高处理速度。但如果你仅仅是读取数据,那么单线程的情况可能会比多线程要快,因为它避免了线程上下文切换的开销。
为什么要绑核?
1 | taskset -c 1 ./proName |
将proName绑定在第二个核。1
taskset -c 1-3 ./proName
绑定运行在第二个到第四个核。
1 | taskset -p 3569 |
查看进程3569当前运行在哪个核上。
mask f转为二进制即为1111,因此四个核都有运行。
当然除了命令行,还有函数接口可以使用,这里就不再扩展了。
物理CPU个数,就是你实际CPU的个数:1
2 cat /proc/cpuinfo | grep "physical id" | sort -u | wc -l
1
CPU物理核数,就是你的一个CPU上有多少个核心,现在很多CPU都是多核:1
2 cat /proc/cpuinfo | grep "core id" | sort -u | wc -l
2
CPU逻辑核数,一颗物理CPU可以有多个物理内核,加上超线程技术,会让CPU看起来有很多个:1
2$ cat /proc/cpuinfo | grep "processor" | sort -u | wc -l
4
线程上下文切换是有开销的,如果它的收益不能超过它的开销,那么使用多线程来提高效率将得不偿失。因此不要盲目推崇多线程。如果为了提高效率采用多线程,那么线程中最多应为逻辑CPU数。也就是说如果你的程序绑在一个核上或者你只有一个CPU一个核,那么采用多线程只能提高同时处理的能力,而不能提高处理效率。
]]>我们的思路是这样的:
举个例子,使用4个线程对11个数据进行排序:1
12,10,4,7,9,6,8,1,5,16,11
由于4不能被10整除,因此,前面三个线程,每个负责排序10%(4-1)= 3三个数,最后一个线程负责排序最后两个数。
线程0 | 线程1 | 线程2 | 线程3 |
---|---|---|---|
12,10,4 | 7,9,6 | 8,1,5 | 16,11 |
假设这4个线程都完成了自己的工作后,内容如下:
线程0 | 线程1 | 线程2 | 线程3 |
---|---|---|---|
4,10,12 | 6,7,9 | 1,5,8 | 11,16 |
最后由主线程将已经排好的每组进行合并:
最终可以得到合并的数据:1
1 4 5 6 7 8 9 10 11 12 16
通过上面的分析,我们需要多个线程进行排序后,一起交给主线程合并,因此需要有方法等待所有线程完成事情之后,再退出。
在《系统编程-多线程》中介绍了pthread_join,今天我们使用pthread_barrier_wait。1
2
3
4
5
int pthread_barrier_destroy(pthread_barrier_t *barrier);
int pthread_barrier_init(pthread_barrier_t *restrict barrier,
const pthread_barrierattr_t *restrict attr, unsigned count);
int pthread_barrier_wait(pthread_barrier_t *barrier);
在解释之前说明一下基本原理,pthread_barrier_wait等待某一个条件达到(计数到达),一旦达到后就会继续往后执行。当然了,如果你希望各个线程完成它自己的工作,主线程再进行合并动作,则你等待的数量可以再加一个。: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
36
37
38
39//来源:公众号【编程珠玑】
//https://www.yanbinghu.com
//barrier.c
pthread_barrier_t b;
void *workThread(void * arg)
{
printf("thread %d\n",*(int*)arg);
pthread_barrier_wait(&b);
return (void*)0;
}
int main(void)
{
int threadNum = 4;
int err;
/*计数为创建线程数+1*/
pthread_barrier_init(&b,NULL,threadNum + 1);
int i = 0;
pthread_t tid;
/*创建多个线程*/
for(i = 0;i < threadNum; i++)
{
err = pthread_create(&tid,NULL,workThread,(void*)&i);
if(0 != err)
{
printf("create thread failed\n");
return -1;
}
printf("tid:%ld\n",tid);
usleep(10000);
}
pthread_barrier_wait(&b);
printf("all thread finished\n");
/*销毁*/
pthread_barrier_destroy(&b);
return 0;
}
其中,pthread_barrier_init用来初始化相关资源,而pthread_barrier_destroy用来销毁相关资源。
编译运行:1
2
3
4
5
6
7
8
9
10
11 gcc -o barrier barrier.c -lpthread
./barrier
tid:140323085256448
thread 0
tid:140323076863744
thread 1
tid:140323068471040
thread 2
tid:140323060078336
thread 3
all thread finished
为了使用qsort函数,我们需要实现自己的比较函数,参考《高级指针话题-函数指针》:1
2
3
4
5
6
7
8
9
10
11
12
13
14//来源:公众号【编程珠玑】
//https:www.yanbinghu.com
/*比较函数*/
int compare(const void* num1, const void* num2)
{
long l1 = *(long*)num1;
long l2 = *(long*)num2;
if(l1 == l2)
return 0;
else if(l1 < l2)
return -1;
else
return 1;
}
对于每个线程完成它自己的任务之后,需要合并所有内容,关于合并的逻辑前面已经举例了,这里不再多介绍。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//来源:公众号【编程珠玑】
//https://www.yanbinghu.com
/*要排序的数组信息*/
typedef struct SortInfo_t
{
long startIdx; //数组启始下标
long num;//要排序的数量
}SortInfo;
/*合并线程已经排序好的内容*/
void merge(SortInfo *sortInfos,size_t threadNum)
{
long idx[threadNum];
memset(idx,0,threadNum);
long i,minidx,sidx,num;
for(i = 0;i < threadNum;i++)
{
idx[i] = sortInfos[i].startIdx;
}
for(sidx = 0;sidx < NUM;sidx++)
{
num = LONG_MAX;
for(i = 0;i < threadNum;i++)
{
if(idx[i] < (sortInfos[i].startIdx + sortInfos[i].num) && (nums[idx[i]] < num))
{
num = nums[idx[i]];
minidx = i;
}
}
snums[sidx] = nums[idx[minidx]];
idx[minidx]++;
}
}
关于生成方法,参考《随机数生成的N种姿势》。
1 | /*来源:公众号【编程珠玑】 |
或阅读原文查看。
对800W数据进行排序,排序时间:1
2
3$ threadSort 1
thread num:1
time 2.369488
使用4个线程时:1
2
3$ threadSort 4
thread num:4
time 1.029097
可以看到速度提升是比较明显的。
可以看到使用4线程排序要比单个线程排序快很多,不过以上实现仅供参考,本文例子可能也存在不妥之处,请根据实际数据情况选择合适的排序算法。但是,多线程就一定快吗?敬请关注下一篇。
参考:《unix环境高级编程》
]]>rand函数声明如下:1
2
int rand(void);
rand函数返回[0,RAND_MAX)范围的随机整数,在我的机器上,RAND_MAX为2147483647。
使用示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/*来源:公众号【编程珠玑】
rand.c
*/
int main(void)
{
int i = 0;
while(i < 5)
{
printf("%d ",rand());
i++;
}
printf("\n");
return 0;
}
编译运行:1
2
3$ gcc -o rand rand.c
./rand
1804289383 846930886 1681692777 1714636915 1957747793
多运行几次,你就会惊喜地发现,每次运行的结果都是一样的!!!这还玩个毛线?
别急,rand虽然每次运行的结果都是一样的,那是因为它的种子默认为1。每一个种子会有一串看似随机的序列,每次取下一个出来,整体都近乎是随机分布的,但是如果你的种子每次都是一样的,那么每次运行可能得到的结果也是一样的。我们需要利用srand给它一个种子。1
2
void srand(unsigned int seed);
为了保证我们每次的得到的随机数不一样,我们必须在每次调用时,都确保种子不一样,因此通常会选择使用时间作为种子。注意这只是通常的种子选择,你可以根据实际使用需求进行选择。
于是我们在使用之前设置好种子,使用示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18/*来源:公众号【编程珠玑】
rand.c
*/
int main(void)
{
srand(time(NULL));//设置随机种子,注意只需要设置一次即可
int i = 0;
while(i < 5)//生成5个随机数
{
printf("%d ",rand());
i++;
}
printf("\n");
return 0;
}
现在好了,每次运行生成的都不一样了。但是还有一个问题,如果这种方式在多线程下使用,也是不可取的,因为rand不是可重入函数。它的每次调用都会修改一些隐藏的属性,因此在多线程中并不会使用它。
为了在多线程下使用,我们使用rand_r,使用方式和rand是一样的:1
2
int rand_r(unsigned int *seedp);
使用示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(void)
{
unsigned int seed = time(NULL);
int i = 0;
while(i < 5)//生成5个随机数
{
printf("%d ",rand_r(&seed));
i++;
}
printf("\n");
return 0;
}
多线程中,多个线程可能几乎同时调用,那它们的种子可能也一样,如果想不一样,还可以将种子设置成和线程id有关。1
unsigned int seed = time(NULL)^pthread_self();
通过前面的例子可以发现,rand生成的整数范围是有限的,为了生成更大范围,可以使用random:1
2
3
long int random(void);
void srandom(unsigned int seed);
random返回的类型为long int,因此在一定程度上,它生成的范围要大得多。另外与rand类似,需要使用srandom函数设置种子。具体的例子就不再放出了。
前面的例子都是生成[1,RAND_MAX]之间的数,如果要生成指定区间的随机数呢?假设a和b不超过int范围以及它们的差值不超过rand的生成范围。
左闭右开区间,即包含a,不包含:1
(rand() % (b - a)) + a;
左闭右闭,即包含a和b:1
(rand() % (b - a + 1)) + a;
左开右闭,即不包含a,包含b:1
(rand() % (b-a)) + a + 1;
1 | rand()/(double)RAND_MAX; |
生成[2,10)之间的随机数5个:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18//来源:公众号【编程珠玑】
int main(void)
{
srand(time(NULL));//设置随机种子,注意只需要设置一次即可
int i = 0;
int a = 2;
int b = 10;
while(i < 5)//生成5个随机数
{
printf("%d ",( rand() % ( b - a ) )+ a);
i++;
}
printf("\n");
return 0;
}
记住,通过这些方法生成的都是伪随机数,而一个好的随机算法,它的随机性很强,可能需要根据使用场景去设计具体的算法。
]]>在说明这些常见出错之前,就必须先了解其基本用法了。需要注意的是,write/read是不带缓冲的,调用一次,写一次。与fwrite/fread有区别,另外write/read为系统调用,频繁地系统调用将会增加开销,可参考《库函数和系统调用的区别》。1
2
3
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
参数解释:
这里有两点需要注意一下。
返回值为ssize_t类型,因为它的返回值可以为负,表示出错,有趣的是这样一来使得其能表示的读写字节范围少了近一半。
返回大于0,表示读或写入对应的字节数。对于read,返回0表示到文件结尾。
另外,我们还注意到,write函数的第二个参数由const修饰。为什么要使用const来修饰?
很显然,在写的过程中,write函数不应该对buf的内容进行修改,它仅仅是从buf中读取罢了。这里在编码时常用的设计,如果不希望该函数修改其内容,则加上const限定符。const详细说明参考《const关键字到底该怎么用?》。
那么返回的读写大小,和参数里的count大小有何区别?前者是真实读写的字节数,而后者是期望读写的字节数。举个简单的例子,文件中有16字节内容,而你尝试读64字节,自然最终只会读到16字节。
正常读写的例子如下: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
36
37
38
39
40//来源:公众号【编程珠玑】
//博客:https://www.yanbinghu.com
//file.c
int main()
{
char writeBuf[] = "https://www.yanbinghu.com";
char readBuf[128] = {0};
/*可读可写,不存在时创建,有内容时截断*/
int fd = open("test.txt",O_RDWR | O_CREAT | O_TRUNC);
if(-1 == fd)
{
perror("open failed");
return -1;
}
/*写内容*/
ssize_t wLen = write(fd,writeBuf,sizeof(writeBuf));
if(wLen < 0)
{
perror("write failed");
close(fd);
return -1;
}
printf("write len:%ld\n",wLen);
ssize_t rLen = read(fd,readBuf,sizeof(readBuf));
if(rLen < 0)
{
perror("read failed");
close(fd);
return -1;
}
readBuf[sizeof(readBuf)-1] = 0;
printf("read content:%s\n",readBuf);
close(fd);
return 0;
}
编译运行,然后你就会惊喜地发现,结果并不是如你想地那样:1
2
3
4 gcc -o writeFile file.c
./writeFile
write len:26
read content:
我们查看文件可以看到内容已经写进去了,但是读取出来地内容却是空!
这是为何?
理解这个问题需要理解文件描述符和偏移量。
文件描述符虽然只是一个整型值,但它只是一个索引值,它指向了该进程打开文件的记录表。还记得常说的“一切皆文件”吗?实际上,即使你每打开一个TCP链接,都会有一个对应的文件描述符。这个记录表中包含了很多与文件相关地信息,例如文件偏移量,inode,状态标志等等。
而你每一次进行读写,都会影响所谓地文件偏移量。
因此你在第一次进行写之后,文件偏移量类似于下面这样:
那么你进行第一次读的时候,文件偏移已经到文件的末尾了(此时函数返回值为0),所以你肯定读不出任何内容,因此你需要移动偏移指针。
为了读取写入后的内容,我们必须要设置偏移量,设置成像下面这样:
有人可能会好奇,这最后为什么还有一个\0?很显然,它被自动加上了,具体原因可以参考《NULL,0,’0’你真的分清了吗》。
还有人会问,你怎么看出有一个\0?用od命令看一下就知道了。1
2
3
4 od -c test.txt
0000000 h t t p s : / / w w w . y a n b
0000020 i n g h u . c o m \0
0000032
现在看到了吧。
为了设置偏移量,我们需要用到函数lseek:1
2
off_t lseek(int fd, off_t offset, int whence);
成功返回新的文件偏移量,出错返回-1。
有必要对参数进行解释
其中whence有三个值
举个例子,假设当前offset为-4,whence为SEEK_CUR,那么当写完内容,并设置该选项后的文件偏移位置如下:
注意,offset是可以为负的。
说白了可以设置偏移位置,而设置可以相对三个位置,开头,当前和结尾。
好了,为了读取到我们写入的内容,我们已经知道怎么做了,就是设置偏移量在文件开头,即在读之前加上下面的语句:1
lseek(fd, 0, SEEK_SET);//注意检查返回值
然后再次编译运行:1
2write len:26
read content:https://www.yanbinghu.com
如你所愿!
使用不当或者出错的时候会有错误信息,这在编码的时候就需要注意检查。
通常使用了一个并不合法的文件描述符,例如,该文件描述符已经关闭。通常你可以通过下面的命令来观察文件描述符的打开情况:1
$ ls -al /proc/`pidof procName`/fd/
这里的procName是你正在运行的程序名。
也有可能是你打开模式不对,例如,以只读方式打开,却尝试写。
通常是在读写过程中被中断,常见的如对socket进行读写时,链接被意外中断,或者读写时,进程被中断等等。
通常在你想创建一个文件,但是文件已经存在的情况。
就如字面意思,通常是文件或者目录不存在,也许你使用了O_CREATE标志,但是如果你的目录不存在,文件也无法创建成功。
还有一种情况是,你已经打开了该文件,程序执行过程中,该文件又被人删除了,删除后又创建了一个文件名一样的文件,这样的情况下,也有可能会提示该错误。
进程打开的文件过多。一个进程打开的文件数量是有限的,具体可以通过:1
2$ ulimit -n
65535
至于当前已经打开了多少,可以这样统计:1
$ ls -l /proc/`pidof proName`/fd/ |wc -l
proName为你的进程名。
一些常见错误中很多涉及到网络的读写,这里暂时没有提及。
一般情况,不会用同一个文件描述符对文件进行既读又写,一旦出现这样的场景时,需要注意偏移量的设置。虽然本文的I/O函数不带缓冲,但是读写时,选择合适的buf大小也非常关键。
另外编程中也有以下建议:
很显然,多线程能够同时执行多个任务。举个例子,你打开某视频播放器,点击下载某个视频,然后你发现这个时候一直在下载,其他啥都干不了,那你肯定骂*。所以在这种情况下,可以使用多线程,让下载任务继续,同时也能继续其他操作。
作为一个包工头,一堆砖要搬,但是就一个人,可是你只能搬这么多,怎么办?多找几个人一起搬呗,但是其他人就也需要付工钱,没关系,能早点干完也就行了,反正总体工钱差不多。
同样的,如果有一个任务特别耗时,而这个任务可以拆分为多个任务,那么就可以让每个线程去执行一个任务,这样任务就可以更快地完成了。
听起来都很好,但是多线程是有代价的。由于它们“同时”进行任务,那么它们任务的有序性就很难保障,而且一旦任务相关,它们之间可能还会竞争某些公共资源,造成死锁等问题。
通过下面的命令可将进程proName程序绑在1核运行:1
taskset -c 1 ./proName
而如果只绑定了一个核,那么同一时刻,只有一个线程在运行,而线程之间的切换又会消耗资源,那么这种情况下反而会导致性能降低。
另外一种情况,就是设置的线程数大于总的逻辑CPU数:1
2$ cat /proc/cpuinfo| grep "processor"| wc -l
8
这样的情况下,设置更多的线程并不会提高处理速度。
优点:
缺点:
普通的进程通常只有一个线程,称为主线程。
创建线程需要使用下面的函数:1
2
3
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
参数有必要做一下说明
创建成功时,返回0,否则出错。
看到了吗,到处都有void*的身影(参考《void*是什么玩意》)。
使用时注意包含头文件1
#include <pthread.h>
,并且在链接时加上-lpthread,因此它不在libc库中。在《一个奇怪的链接问题》中提到,对于非glibc库中的库函数,都需要显式链接对应的库。
试着写一个简单的多线程程序,简单起见,我们暂时不设置任何属性,将attr字段设置为NULL:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22//来源:公众号【编程珠玑】
//main.c
void *myThread(void *id)
{
printf("thread run,value is %d\n",*(int*)id);
//return NULL; 这种方式也可以退出线程
pthread_exit((void*)0);//退出线程
}
int main(void)
{
pthread_t tid ;
int i = 10;
int status = pthread_create(&tid,NULL,myThread,(void*)&i);
if(status < 0 )
{
printf("crete failed\n");
}
printf("main func finished\n");
return 0;
}
编译运行:1
2
3 gcc -o main main.c -lpthread
./main
main func finished
发现运行的结果并不如我们预期那样,就好像线程没有执行一样。
原因在于,如果主线程退出了,那么其他线程也会退出。所谓,皮之不存,毛将焉附,所有线程都共同使用很多资源,相关内容也可以从《对进程和线程的一些总结》中了解到。
如何改进呢?我们可以等线程执行完啊,于是,在主线程退出前sleep:1
2
3
4
5
6
7
8
9
10
11
12
13int main(void)
{
pthread_t tid ;
int i = 10;
int status = pthread_create(&tid,NULL,myThread,(void*)&i);
if(status < 0 )
{
printf("crete failed\n");
}
printf("main func finished\n");
sleep(1);
return 0;
}
这样就好了(注意添加头文件1
2
3```
main func finished
thread run,value is 10
但是你会发现,1
2
3
4
5但是转念一想,如果线程执行的时间超过一秒呢,难道就要sleep更长时间吗?而很多时候甚至根本不知道线程要执行多长时间,那怎么办呢?
还可以使用:
```c
int pthread_join(pthread_t thread, void **retval);
thread是前面获得的线程id,而retval包含了线程的返回信息,假设我们完全不关心线程的退出状态,那么可以设置为NULL。
修改代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13int main(void)
{
pthread_t tid ;
int i = 10;
int status = pthread_create(&tid,NULL,myThread,(void*)&i);
if(status < 0 )
{
printf("crete failed\n");
}
printf("main func finished\n");
pthread_join(tid,NULL);
return 0;
}
这种情况同样可以达到目的,pthread_join,会阻塞程序,直到线程退出(前提是线程为非分离线程)。
以下几种情况下,线程会终止
1 | int main(void) |
在创建线程后,修改i的值,你会发现在线程中打印的不会是10,而是6。
也就是说,创建线程的时候,传入的参数必须确保其使用这个参数时,参数没有被修改,否则的话,拿到的将是错误的值,
本文通过一些小例子,简单介绍了线程概念,对于绑核,多线程同步等问题均一笔带过,将在后面的文章中继续介绍。
]]>void*到底是怎样的存在?
在说明void*之前,先了解一下普通指针类型的含义。
1 | //来源:公众号【编程珠玑】 |
上面的输出结果为:1
2b+1:2019
c+1:3
对于上面的结果,也许你并不感到意外。如果你的疑问是为什么不是2而是3,那么建议你看看《理一理字节序的事》。同样是指针类型,b和c有什么区别?
一个是指向整型的指针,一个是指向char型的指针,当它们执行算术运算时,它们的步长就是对应类型占用空间大小。
即1
b + 1 //移动sizeof(int)字节
04 | 03 | 02 | 01 | 2019 |
---|---|---|---|---|
字节0 | 字节1 | 字节2 | 字节3 | 字节4~7 |
↑ |
指针移动4个字节后,指向的就是2019了,解引用自然得到2019。
而对于c1
c + 1 //移动sizeof(char)字节
它的指向如下:
04 | 03 | 02 | 01 | 2019 |
---|---|---|---|---|
字节0 | 字节1 | 字节2 | 字节3 | 字节4~7 |
↑ |
解引用之后,自然得到3。
各种类型之间没有本质区别,只是解释内存中的数据方式不同。
例如,对于int型指针b,解引用时,会解析4字节,算术运算时,也是以该类型占用空间大小为单位,所以b+1,移动4字节,解引用,处理4字节内容,得到2019。
对于char型指针c,解引用时,会解析1个字节,算术运算时,也是以sizeof(char)为单位,所以c+1,移动一字节,解引用,处理1字节,得到03。
所以像下面这样的操作:1
2char a[] = {01,02,03,04};
int *b = (int*)a+2;
如果你试图解引用b,即*b,就可能遇到无法预料的问题,因为将会访问非法内存位置。
a+2,移动sizeof(char)字节,指向03,此时按照int类型指针解引用,由于int类型解引用会处理4字节内存,但是后面已经没有属于数组a的合法内容了,因此可能出错。
正由于它们没有本质区别,它们占用空间大小在同一个程序中都是固定的,对于32位程序,占用4字节空间,64位占用8字节,而正因如此,64位程序理论能使用的内存是足够大的,而32位程序理论上能使用的不过4G(2^(4*8bit)),再加上内核空间的使用,真正能用到的可能就3G左右。
如果你的系统是64位的,那么默认情况下,编译出来的程序也是64位的。如果你想编译为32位,可以使用-m32参数:1
$ gcc -m32 -o main main.c
如何确定是多少位的程序:1
2$ readelf -h main
Class: ELF32
上面的ELF32,表明了它是32位程序。或者可以看Machine字段:1
Machine: Intel 80386
说回void*,前面说了,指针的类型不过是解释数据的方式不同罢了,这样的道理也可用于很多场合的强制类型转换,例如将int类型指针转换为char型指针,并不会改变内存的实际内容,只是修改了解释方式而已。而void 是一种无类型指针,任何类型指针都可以转为void\,它无条件接受各种类型。
而既然是无类型指针,那么就不要尝试做下面的事情:
由于不知道其解引用操作的内存大小,以及算术运算操作的大小,因此它的结果是未知的。1
2
3
4
5
6
7
8
9
int main(void)
{
int a = 10;
int *b = &a;
void *c = b;
*c;
return 0;
}
编译警告如下:1
warning: dereferencing ‘void *’ pointer
既然如此,那么void*有什么用呢?
实际上我们在很多接口中都会发现它们的参数类型都是void*,例如:1
2ssize_t read(int fd, void *buf, size_t count);
void *memcpy(void *dest, const void *src, size_t n);
为何要如此设计?因为对于这种通用型接口,你不知道用户的数据类型是什么,但是你必须能够处理用户的各种类型数据,因而会使用void*。void*能包容地接受各种类型的指针。
也就是说,如果你期望接口能够接受任何类型的参数,你可以使用void*类型。
但是在具体使用的时候,你必须转换为具体的指针类型。例如,你传入接口的是int*,那么你在使用的时候就应该按照int*使用。
使用void*需要特别注意的是,你必须清楚原始传入的是什么类型,然后转换成对应类型。例如,你准备使用库函数qsort进行排序:1
void qsort(void *base,size_t nmemb,size_t size , int(*compar)(const void *,const void *));
它的第三个参数就是比较函数,它接受的参数都是const void*,如果你的比较对象是一个结构体类型,那么你自己在实现compar函数的时候,也必须是转换为该结构体类型使用。
举个例子,你要实现学生信息按照成绩比较:1
2
3
4
5
6
7
8
9
10
11
12
13
14//来源:公众号【编程珠玑】
typedef struct student_tag
{
char name[STU_NAME_LEN]; //学生姓名
unsigned int id; //学生学号
int score; //学生成绩
}student_t;
int studentCompare(const void *stu1,const void *stu2)
{
/*强转成需要比较的数据结构*/
student_t *value1 = (student_t*)stu1;
student_t *value2 = (student_t*)stu2;
return value1->score-value2->score;
}
在将其传入studentCompare
函数后,必须转换为其对应的类型进行处理。
更多函数指针相关内容可以参考《高级指针话题-函数指针》。
void*很强大,但是一定要在合适的时候使用;同时强转很逆天,但是一定要注意前后的类型是否真的能正确转换。
通俗地说void*: