UIUC CS241 讲义:众包系统编程书(2)https://developer.aliyun.com/article/1427160
分叉,第一部分:介绍
警告
进程分叉是一个非常强大(也非常危险)的工具。如果出错并导致分叉炸弹(稍后在本页解释),你可能会导致整个系统崩溃。为了减少这种可能性,通过在命令行中输入ulimit -u 40
来将最大进程数限制为一个小数字,例如 40。请注意,此限制仅适用于用户,这意味着如果你引发了分叉炸弹,那么你将无法杀死你刚刚创建的所有进程,因为调用killall
需要你的 shell 来fork()
…讽刺吧?那么我们该怎么办呢?一个解决方案是提前生成另一个用户(例如 root)的另一个 shell 实例并从那里杀死进程。另一个方法是使用内置的exec
命令杀死所有用户进程(小心,你只有一次机会)。最后,你可以重新启动系统 😃
在测试fork()
代码时,请确保你有根用户和/或物理访问权限。如果你必须远程处理fork()
代码,请记住,在紧急情况下kill -9 -1会帮助你。
总结:如果你没有准备好,fork
可能会非常危险。你已经被警告过了。
分叉介绍
fork
做什么?
fork
系统调用克隆当前进程以创建一个新进程。它通过复制现有进程的状态创建一个新进程(子进程),有一些细微的差异(下面讨论)。子进程不是从 main 开始。相反,它像父进程一样从fork()
返回。
什么是最简单的fork()
例子?
这是一个非常简单的例子…
printf("I'm printed once!\n"); fork(); // Now there are two processes running // and each process will print out the next line. printf("You see this line twice!\n");
为什么这个例子会打印两次 42?
以下程序打印出 42 两次 - 但fork()
在printf
之后!?为什么?
#include <unistd.h> /*fork declared here*/ #include <stdio.h> /* printf declared here*/ int main() { int answer = 84 >> 1; printf("Answer: %d", answer); fork(); return 0; }
printf
行*只执行一次,但请注意打印的内容没有刷新到标准输出(没有打印换行,我们没有调用fflush
或更改缓冲模式)。因此,输出文本仍然在进程内存中等待发送。当执行fork()
时,整个进程内存被复制,包括缓冲区。因此,子进程从一个非空输出缓冲区开始,该缓冲区将在程序退出时刷新。
如何编写针对父进程和子进程不同的代码?
检查fork()
的返回值。返回值-1
= 失败;0
= 在子进程中;正数 = 在父进程中(返回值是子进程 id)。以下是记住哪个是哪个的一种方法:
子进程可以通过调用getppid()
找到其父进程 - 被复制的原始进程 - 因此不需要从fork()
获得任何额外的返回信息。然而,父进程只能从fork
的返回值中找到新子进程的 id:
pid_t id = fork(); if (id == -1) exit(1); // fork failed if (id > 0) { // I'm the original parent and // I just created a child process with id 'id' // Use waitpid to wait for the child to finish } else { // returned zero // I must be the newly made child process }
什么是分叉炸弹?
'分叉炸弹’是指尝试创建无限数量的进程。下面是一个简单的例子:
while (1) fork();
这通常会使系统几乎停滞,因为它试图为大量准备运行的进程分配 CPU 时间和内存。评论:系统管理员不喜欢分叉炸弹,可能会设置每个用户可以拥有的进程数量的上限,或者可能会撤销登录权限,因为它会为其他用户的程序带来麻烦。你也可以使用setrlimit()
来限制创建的子进程数量。
分叉炸弹并不一定是恶意的 - 它们偶尔会由于学生编码错误而发生。
Angrave 建议《黑客帝国》三部曲,机器和人最终共同努力击败不断增殖的 Agent-Smith,是基于一个基于 AI 驱动的分叉炸弹的电影情节。
等待和执行
父进程如何等待子进程完成?
使用waitpid
(或wait
)。
pid_t child_id = fork(); if (child_id == -1) { perror("fork"); exit(EXIT_FAILURE);} if (child_id > 0) { // We have a child! Get their exit code int status; waitpid( child_id, &status, 0 ); // code not shown to get exit status from child } else { // In child ... // start calculation exit(123); }
我可以让子进程执行另一个程序吗?
是的。在 fork 后使用其中一个exec
函数。exec
函数集用正在调用的进程映像替换进程映像。这意味着exec
调用后的任何代码行都将被替换。任何其他要求子进程执行的工作都应该在exec
调用之前完成。
Wikipedia 文章在帮助您理解 exec 系列名称方面做得很好。
命名方案可以缩短如下
每个的基础都是 exec(执行),后面跟着一个或多个字母:
e - 指向环境变量的指针数组被显式传递给新的进程映像。
l - 命令行参数被逐个传递(列表)给函数。
p - 使用 PATH 环境变量来查找要执行的文件名。
v - 命令行参数作为指针数组(向量)传递给函数。
#include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <stdlib.h> #include <stdio.h> int main(int argc, char**argv) { pid_t child = fork(); if (child == -1) return EXIT_FAILURE; if (child) { /* I have a child! */ int status; waitpid(child , &status ,0); return EXIT_SUCCESS; } else { /* I am the child */ // Other versions of exec pass in arguments as arrays // Remember first arg is the program name // Last arg must be a char pointer to NULL execl("/bin/ls", "ls","-alh", (char *) NULL); // If we get to this line, something went wrong! perror("exec failed!"); } }
执行另一个程序的简单方法
使用system
。以下是如何使用它的方法:
#include <unistd.h> #include <stdlib.h> int main(int argc, char**argv) { system("ls"); return 0; }
system
调用将分叉,执行由参数传递的命令,原始父进程将等待其完成。这也意味着system
是一个阻塞调用:父进程在由system
启动的进程退出之前无法继续。这可能有用,也可能没有。此外,system
实际上创建了一个 shell,然后给出字符串,这比直接使用exec
更耗费资源。标准 shell 将使用PATH
环境变量搜索与命令匹配的文件名。对于许多简单的运行此命令问题,使用 system 通常足够了,但对于更复杂或微妙的问题,它可能很快变得有限,并且它隐藏了分叉-执行-等待模式的机制,因此我们鼓励您学习并使用fork
exec
和waitpid
。
最愚蠢的 fork 示例是什么?
下面显示了一个稍微愚蠢的例子。它会打印什么?尝试使用多个参数运行您的程序。
#include <unistd.h> #include <stdio.h> int main(int argc, char **argv) { pid_t id; int status; while (--argc && (id=fork())) { waitpid(id,&status,0); /* Wait for child*/ } printf("%d:%s\n", argc, argv[argc]); return 0; }
令人惊奇的并行明显 O(N) sleepsort是今天的愚蠢赢家。首次发布于2011 年的 4chan。下面显示了这种糟糕但有趣的排序算法的一个版本。
int main(int c, char **v) { while (--c > 1 && !fork()); int val = atoi(v[c]); sleep(val); printf("%d\n", val); return 0; }
注意:由于系统调度程序的工作方式,该算法实际上并不是 O(N)。虽然有并行算法可以在每个进程中以 O(log(N))运行,但这不幸地不是其中之一。
子进程与父进程有什么不同?
关键区别包括:
- 由
getpid()
返回的进程 ID。由getppid()
返回的父进程 ID。 - 当子进程完成时,父进程通过信号 SIGCHLD 被通知,但反之则不然。
- 子进程不会继承未决信号或定时器警报。完整列表请参阅fork man page
子进程共享打开的文件句柄吗?
是的!实际上,两个进程都使用相同的底层内核文件描述符。例如,如果一个进程将随机访问位置倒回到文件的开头,那么两个进程都会受到影响。
子进程和父进程都应该close
(或fclose
)它们的文件描述符或文件句柄。
如何获取更多信息?
阅读 man 页面!
分叉,第二部分:分叉,执行,等待
模式
以下的’exec’示例是做什么的?
#include <unistd.h> #include <fcntl.h> // O_CREAT, O_APPEND etc. defined here int main() { close(1); // close standard out open("log.txt", O_RDWR | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR); puts("Captain's log"); chdir("/usr/include"); // execl( executable, arguments for executable including program name and NULL at the end) execl("/bin/ls", /* Remaining items sent to ls*/ "/bin/ls", ".", (char *) NULL); // "ls ." perror("exec failed"); return 0; // Not expected }
上述代码中没有错误检查(我们假设 close、open、chdir 等都按预期工作)。
- open:将使用最低可用的文件描述符(即 1);因此标准输出现在转到日志文件。
- chdir:将当前目录更改为/usr/include
- execl:用/bin/ls 替换程序图像,并调用它的 main()方法
- perror:我们不希望到达这里 - 如果到达了,那么 exec 失败了。
微妙的 fork 炸弹错误
这段代码有什么问题
#include <unistd.h> #define HELLO_NUMBER 10 int main(){ pid_t children[HELLO_NUMBER]; int i; for(i = 0; i < HELLO_NUMBER; i++){ pid_t child = fork(); if(child == -1){ break; } if(child == 0){ //I am the child execlp("ehco", "echo", "hello", NULL); } else{ children[i] = child; } } int j; for(j = 0; j < i; j++){ waitpid(children[j], NULL, 0); } return 0; }
我们拼错了ehco
,所以我们无法exec
它。这是什么意思?我们只创建了 2**10 个进程,而不是 10 个进程,炸毁了我们的机器。我们如何防止这种情况?在 exec 后立即放置一个退出,这样如果 exec 失败,我们就不会炸毁我们的机器。
子进程从父进程继承了什么?
- 打开文件句柄。如果父进程稍后寻求,比如,回到文件的开头,那么这也会影响子进程(反之亦然)。
- 信号处理程序
- 当前工作目录
- 环境变量
有关更多详细信息,请参阅fork man page。
子进程与父进程有什么不同?
进程 ID 是不同的。在子进程中调用getppid()
(注意两个’p’)将得到与在父进程中调用 getpid()相同的结果。有关更多详细信息,请参阅 fork man page。
我如何等待我的子进程完成?
使用waitpid
或wait
。父进程将暂停,直到wait
(或waitpid
)返回。请注意,此解释忽略了重新启动的讨论。
fork-exec-wait 模式是什么
一个常见的编程模式是调用fork
,然后是exec
和wait
。原始进程调用 fork,创建一个子进程。然后子进程使用 exec 来启动一个新程序的执行。与此同时,父进程使用wait
(或waitpid
)来等待子进程完成。请参阅下面的完整代码示例。
我如何启动一个同时运行的后台进程?
不要等待它们!您的父进程可以继续执行代码,而无需等待子进程。请注意,在实践中,通过在调用 exec 之前关闭打开的文件描述符,后台进程也可以与父进程的输入和输出流断开连接。
然而,在父进程完成之前完成的子进程可能会变成僵尸。有关更多信息,请参阅僵尸页面。
僵尸
好的父母不会让他们的孩子变成僵尸!
当一个子进程完成(或终止)时,它仍然占据内核进程表中的一个槽。只有在子进程被“等待”后,该槽才会再次可用。
一个长时间运行的程序可能会通过不断创建进程而永远不等待它们来创建许多僵尸。
太多僵尸会有什么影响?
最终,内核进程表中会没有足够的空间来创建新进程。因此,fork()
会失败,并且可能使系统难以/无法使用 - 例如,仅登录就需要一个新进程!
系统如何帮助防止僵尸?
一旦一个进程完成,它的任何子进程都将被分配给“init” - 具有 pid 为 1 的第一个进程。因此,这些子进程将看到 getppid()返回值为 1。这些孤儿最终会完成,并在短暂的时刻成为僵尸。幸运的是,init 进程会自动等待它的所有子进程,从而将这些僵尸从系统中移除。
我如何防止僵尸?(警告:简化的答案)
等待你的孩子!
waitpid(child, &status, 0); // Clean up and wait for my child process to finish.
请注意,我们假设获得 SIGCHLD 事件的唯一原因是子进程已经完成(这并不完全正确 - 有关更多详细信息,请参阅 man page)。
一个健壮的实现还会检查中断状态,并在循环中包含上述内容。继续阅读,了解更健壮的实现的讨论。
我如何使用 SIGCHLD 异步等待我的子进程?(高级)
警告:本节使用了我们尚未完全介绍的信号。当子进程完成时,父进程会收到 SIGCHLD 信号,因此信号处理程序可以等待该进程。下面显示了一个稍微简化的版本。
pid_t child; void cleanup(int signal) { int status; waitpid(child, &status, 0); write(1,"cleanup!\n",9); } int main() { // Register signal handler BEFORE the child can finish signal(SIGCHLD, cleanup); // or better - sigaction child = fork(); if (child == -1) { exit(EXIT_FAILURE);} if (child == 0) { /* I am the child!*/ // Do background stuff e.g. call exec } else { /* I'm the parent! */ sleep(4); // so we can see the cleanup puts("Parent is done"); } return 0; }
然而,上面的例子忽略了一些微妙的地方:
- 可能有多个子进程已经完成,但父进程只会收到一个 SIGCHLD 信号(信号不会排队)
- SIGCHLD 信号可能是因为其他原因而发送的(例如,子进程暂时停止)
下面显示了一个更健壮的代码来清除僵尸进程。
void cleanup(int signal) { int status; while (waitpid((pid_t) (-1), 0, WNOHANG) > 0) {} }
那么什么是环境变量?
环境变量是系统为所有进程保留的变量。您的系统现在已经设置了这些!在 Bash 中,您可以检查其中一些
$ echo $HOME /home/bhuvy $ echo $PATH /usr/local/sbin:/usr/bin:...
如何在 C/C++中获取这些?您可以使用getenv
和setenv
函数
char* home = getenv("HOME"); // Will return /home/bhuvy setenv("HOME", "/home/bhuvan", 1 /*set overwrite to true*/ );
那么这些环境变量对父进程/子进程有什么意义呢?
每个进程都有自己的环境变量字典,这些变量会被复制到子进程中。这意味着,如果父进程更改其环境变量,它不会传递给子进程,反之亦然。如果您想要使用不同的环境变量执行程序,这在 fork-exec-wait 三部曲中很重要。
例如,您可以编写一个 C 程序,循环遍历所有时区,并执行date
命令以打印出所有本地的日期和时间。环境变量用于各种程序,因此修改它们很重要。
进程控制,第一部分:等待宏,使用信号
等待宏
我可以找出我的子进程的退出值吗?
您可以找到子进程的最低 8 位退出值(main()
的返回值或包含在exit()
中的值):使用“等待宏” - 通常您将使用“WIFEXITED”和“WEXITSTATUS”。有关更多信息,请参阅wait
/waitpid
手册页。
int status; pid_t child = fork(); if (child == -1) return 1; //Failed if (child > 0) { /* I am the parent - wait for the child to finish */ pid_t pid = waitpid(child, &status, 0); if (pid != -1 && WIFEXITED(status)) { int low8bits = WEXITSTATUS(status); printf("Process %d returned %d" , pid, low8bits); } } else { /* I am the child */ // do something interesting execl("/bin/ls", "/bin/ls", ".", (char *) NULL); // "ls ." }
一个进程只能有 256 个返回值,其余的位是信息性的。
位移
请注意,没有必要记住这些,这只是信息存储在状态变量内部的高级概述
/* 如果 WIFEXITED(STATUS),则为状态的低 8 位。 */ #define __WEXITSTATUS(status) (((status) & 0xff00) >> 8) /* 如果 WIFSIGNALED(STATUS),则为终止信号。 */ #define __WTERMSIG(status) ((status) & 0x7f) /* 如果 WIFSTOPPED(STATUS),则为停止子进程的信号。 */ #define __WSTOPSIG(status) __WEXITSTATUS(status) /* 如果 STATUS 指示正常终止,则为非零。 */ #define __WIFEXITED(status) (__WTERMSIG(status) == 0)
内核有一种内部方式来跟踪已发出、已退出或已停止的信号。该 API 被抽象化,以便内核开发人员可以随意更改。
小心。
请记住,如果前提条件得到满足,宏才有意义。这意味着如果进程被发出信号,进程的退出状态将不会被定义。宏不会为您检查,因此需要编程确保逻辑正确。
信号
什么是信号?
信号是内核提供给我们的一种构造。它允许一个进程异步地向另一个进程发送信号(类似于消息)。如果该进程希望接受该信号,它可以,并且对于大多数信号,可以决定如何处理该信号。这里是一个信号的简短列表(非全面)。
名称 | 默认操作 | 通常用例 |
SIGINT |
终止进程(可以被捕获) | 告诉进程停止运行 |
SIGQUIT |
终止进程(可以被捕获) | 告诉进程停止运行 |
SIGSTOP |
停止进程(无法被捕获) | 停止进程以便继续 |
SIGCONT |
继续进程 | 继续运行进程 |
SIGKILL |
终止进程(无法被忽略) | 你想让你的进程消失 |
我可以暂停我的子进程吗?
是的!您可以通过发送 SIGSTOP 信号临时暂停正在运行的进程。如果成功,它将冻结一个进程;即进程将不再分配任何 CPU 时间。
要允许进程恢复执行,请发送 SIGCONT 信号。
例如,这里有一个程序,每秒慢慢打印一个点,最多 59 个点。
#include <unistd.h> #include <stdio.h> int main() { printf("My pid is %d\n", getpid() ); int i = 60; while(--i) { write(1, ".",1); sleep(1); } write(1, "Done!",5); return 0; }
我们首先将进程在后台启动(注意末尾的&)。然后通过使用 kill 命令从 shell 进程向其发送信号。
>./program & My pid is 403 ... >kill -SIGSTOP 403 >kill -SIGCONT 403
如何在 C 中杀死/停止/暂停我的子进程?
在 C 中,使用kill
POSIX 调用向子进程发送信号,
kill(child, SIGUSR1); // Send a user-defined signal kill(child, SIGSTOP); // Stop the child process (the child cannot prevent this) kill(child, SIGTERM); // Terminate the child process (the child can prevent this) kill(child, SIGINT); // Equivalent to CTRL-C (by default closes the process)
正如我们上面所看到的,在 shell 中也有一个 kill 命令,例如获取正在运行的进程列表,然后终止进程 45 和进程 46
ps kill -l kill -9 45 kill -s TERM 46
如何检测“CTRL-C”并优雅地清理?
我们稍后会回到信号 - 这只是一个简短的介绍。在 Linux 系统上,如果您有兴趣了解更多信息(例如系统和库调用的异步信号安全列表),请参阅man -s7 signal
。
信号处理程序内的可执行代码受到严格限制。大多数库和系统调用都不是“异步信号安全”的 - 它们不能在信号处理程序内使用,因为它们不是可重入安全的。在单线程程序中,信号处理会暂时中断程序执行,以执行信号处理程序代码。假设您的原始程序在执行malloc
库代码时被中断;malloc 使用的内存结构将不处于一致状态。在信号处理程序中调用printf
(它使用malloc
)是不安全的,并将导致“未定义行为”,即它不再是一个有用的、可预测的程序。实际上,您的程序可能会崩溃、计算或生成不正确的结果,或者停止运行(“死锁”),具体取决于在执行信号处理程序代码时您的程序正在执行什么。
信号处理程序的一个常见用途是设置一个布尔标志,该标志偶尔被轮询(读取)作为程序正常运行的一部分。例如,
int pleaseStop ; // See notes on why "volatile sig_atomic_t" is better void handle_sigint(int signal) { pleaseStop = 1; } int main() { signal(SIGINT, handle_sigint); pleaseStop = 0; while ( ! pleaseStop) { /* application logic here */ } /* cleanup code here */ }
上述代码在纸上看起来可能是正确的。然而,我们需要向编译器和将执行main()
循环的 CPU 核心提供一个提示。我们需要防止编译器优化:表达式! pleaseStop
似乎是一个循环不变量,即永远为真,因此可以简化为true
。其次,我们需要确保pleaseStop
的值不会被缓存在 CPU 寄存器中,而是始终从主存中读取和写入。sig_atomic_t
类型意味着变量的所有位可以作为“原子操作”进行读取或修改 - 一个不可中断的操作。不可能读取由一些新位值和旧位值组成的值。
通过使用正确类型的volatile sig_atomic_t
来指定pleaseStop
,我们可以编写可移植的代码,其中主循环将在信号处理程序返回后退出。sig_atomic_t
类型在大多数现代平台上可以与int
一样大,但在嵌入式系统上可能只能表示(-127 至 127)的值,并且只能表示(-127 至 127)的值。
volatile sig_atomic_t pleaseStop;
这种模式的两个示例可以在“COMP”中找到,这是一个基于终端的 1Hz 4 位计算机(github.com/gto76/comp-cpp/blob/1bf9a77eaf8f57f7358a316e5bbada97f2dc8987/src/output.c#L121
)。使用了两个布尔标志。一个用于标记SIGINT
(CTRL-C)的传递,并优雅地关闭程序,另一个用于标记SIGWINCH
信号以检测终端调整大小并重新绘制整个显示。
进程复习问题
主题
- 正确使用 fork、exec 和 waitpid
- 使用带有路径的 exec
- 理解 fork、exec 和 waitpid 的作用。例如,如何使用它们的返回值。
- SIGKILL 与 SIGSTOP 与 SIGINT。
- 按下 CTRL-C 时发送了什么信号?
- 从 shell 或 kill POSIX 调用使用 kill。
- 进程内存隔离。
- 进程内存布局(堆在哪里,栈等;无效的内存地址)。
- 什么是 fork 炸弹、僵尸进程和孤儿进程?如何创建/删除它们。
- getpid 与 getppid
- 如何使用 WAIT 退出状态宏 WIFEXITED 等。
问题/练习
- 带有 p 和不带 p 的 execs 有什么区别?操作系统是什么?
- 如何将命令行参数传递给
execl*
?execv*
呢?按照惯例,第一个命令行参数应该是什么? - 如何知道
exec
或fork
失败了? - 传递给 wait 的
int *status
指针是什么?wait 何时失败? SIGKILL
、SIGSTOP
、SIGCONT
、SIGINT
之间有哪些区别?默认行为是什么?哪些可以设置信号处理程序?- 按下
CTRL-C
时发送了什么信号? - 我的终端锚定在 PID = 1337,并且刚刚变得无响应。给我写一个终端命令和 C 代码,向其发送
SIGQUIT
。 - 一个进程能否通过正常手段改变另一个进程的内存?为什么?
- 堆、栈、数据和文本段在哪里?哪些段可以写入?无效的内存地址是什么?
- 用 C 语言编写一个 fork 炸弹(请不要运行它)。
- 什么是孤儿进程?它如何变成僵尸进程?如何成为一个好的父进程?
- 当父母告诉你不能做某事时,你是不是很讨厌?给我写一个程序,向你的父进程发送
SIGSTOP
。 - 编写一个 fork exec 等待可执行文件的函数,并使用等待宏告诉我进程是否正常退出或被信号中断。如果进程正常退出,则打印返回值。如果不是,则打印导致进程终止的信号编号。
三、内存和分配器
内存,第一部分:堆内存介绍
C 动态内存分配
当我调用 malloc 时会发生什么?
函数malloc
是一个 C 库调用,用于保留一块连续的内存。与堆栈内存不同,内存保持分配状态,直到使用相同指针调用free
。还有calloc
和realloc
,下面将讨论它们。
malloc 可能失败吗?
如果malloc
无法保留更多内存,则返回NULL
。健壮的程序应该检查返回值。如果您的代码假设malloc
成功,但实际上没有成功,那么当它尝试写入地址 0 时,您的程序很可能会崩溃(段错误)。
堆在哪里,有多大?
堆是进程内存的一部分,它没有固定的大小。当您调用malloc
(calloc
,realloc
)和free
时,C 库将执行堆内存分配。
首先快速回顾一下进程内存:进程是程序的运行实例。每个进程都有自己的地址空间。例如,在 32 位机器上,您的进程大约有 40 亿个地址可供使用,但并非所有这些地址都是有效的,甚至映射到实际的物理内存(RAM)。在进程的内存中,您将找到可执行代码、堆栈空间、环境变量、全局(静态)变量和堆。
通过调用sbrk
,C 库可以根据程序对堆内存的需求增加堆的大小。由于堆和堆栈(每个线程一个)需要增长,我们将它们放在地址空间的相对两端。因此,对于典型的架构,堆将向上增长,堆栈向下增长。
真相:现代操作系统内存分配器不再需要sbrk
-相反,它们可以请求独立的虚拟内存区域并维护多个内存区域。例如,大量请求可以放置在与小分配请求不同的内存区域中。但是,这个细节是一个不需要的复杂性:碎片化和有效分配内存的问题仍然存在,因此我们将忽略这个实现细节,并将其写成堆是一个单一区域的样子。
如果我们编写一个多线程程序(稍后会详细介绍),我们将需要多个堆栈(每个线程一个),但只有一个堆。
在典型的架构中,堆是数据段
的一部分,它从代码和全局变量的上方开始。
程序需要调用 brk 或 sbrk 吗?
通常不需要(尽管调用sbrk(0)
可能会很有趣,因为它告诉您堆当前的结束位置)。相反,程序使用malloc,calloc,realloc
和free
,它们是 C 库的一部分。当需要额外的堆内存时,这些函数的内部实现将调用sbrk
。
void *top_of_heap = sbrk(0); malloc(16384); void *top_of_heap2 = sbrk(0); printf("The top of heap went from %p to %p \n", top_of_heap, top_of_heap2);
示例输出:堆的顶部从 0x4000 到 0xa000
什么是 calloc?
与malloc
不同,calloc
将内存内容初始化为零,并且还接受两个参数(项目的数量和每个项目的字节大小)。一个朴素但可读的calloc
实现如下:
void *calloc(size_t n, size_t size) { size_t total = n * size; // Does not check for overflow! void *result = malloc(total); if (!result) return NULL; // If we're using new memory pages // just allocated from the system by calling sbrk // then they will be zero so zero-ing out is unnecessary, memset(result, 0, total); return result; }
有关这些限制的高级讨论在这里。
程序员通常使用calloc
而不是在malloc
后显式调用memset
,以将内存内容设置为零。请注意,calloc(x,y)
与calloc(y,x)
相同,但您应该遵循手册的约定。
// Ensure our memory is initialized to zero link_t *link = malloc(256); memset(link, 0, 256); // Assumes malloc returned a valid address! link_t *link = calloc(1, 256); // safer: calloc(1, sizeof(link_t));
为什么 sbrk 首次返回的内存初始化为零?
如果操作系统没有清零物理 RAM 的内容,可能会导致一个进程了解到先前使用过该内存的另一个进程的内存。这将是一个安全漏洞。
不幸的是,这意味着对于在释放任何内存之前进行的malloc
请求和简单程序(最终使用系统中新保留的内存)来说,内存通常是零。然后程序员错误地编写了假设 malloc’d 内存将始终为零的 C 程序。
char* ptr = malloc(300); // contents is probably zero because we get brand new memory // so beginner programs appear to work! // strcpy(ptr, "Some data"); // work with the data free(ptr); // later char *ptr2 = malloc(308); // Contents might now contain existing data and is probably not zero
为什么 malloc 不总是将内存初始化为零?
性能!我们希望 malloc 尽可能快。清零内存可能是不必要的。
realloc 是什么,什么时候会用到它?
realloc
允许你调整之前通过 malloc、calloc 或 realloc 在堆上分配的现有内存分配的大小。realloc 最常见的用途是调整用于保存值数组的内存。下面建议一个朴素但可读的 realloc 版本
void * realloc(void * ptr, size_t newsize) { // Simple implementation always reserves more memory // and has no error checking void *result = malloc(newsize); size_t oldsize = ... //(depends on allocator's internal data structure) if (ptr) memcpy(result, ptr, newsize < oldsize ? newsize : oldsize); free(ptr); return result; }
下面显示了 realloc 的错误用法:
int *array = malloc(sizeof(int) * 2); array[0] = 10; array[1] = 20; // Ooops need a bigger array - so use realloc.. realloc (array, 3); // ERRORS! array[2] = 30;
上面的代码包含两个错误。首先,我们需要 3*sizeof(int)字节,而不是 3 字节。其次,realloc 可能需要将内存的现有内容移动到新位置。例如,可能没有足够的空间,因为相邻的字节已经被分配。下面显示了 realloc 的正确用法。
array = realloc(array, 3 * sizeof(int)); // If array is copied to a new location then old allocation will be freed.
一个健壮的版本还会检查NULL
返回值。注意realloc
可以用来增加和缩小分配。
我在哪里可以读到更多信息?
参见man 页面!
内存分配的速度有多重要?
非常重要!在大多数应用程序中,分配和释放堆内存是常见操作。
分配简介
最愚蠢的 malloc 和 free 实现是什么,有什么问题?
void* malloc(size_t size) // Ask the system for more bytes by extending the heap space. // sbrk Returns -1 on failure void *p = sbrk(size); if(p == (void *) -1) return NULL; // No space left return p; } void free() {/* Do nothing */}
上述实现遭受两个主要缺点:
- 系统调用很慢(与库调用相比)。我们应该保留大量内存,只偶尔向系统请求更多。
- 不重用释放的内存。我们的程序从不重用堆内存-它只是不断地要求更大的堆。
如果这个分配器在一个典型的程序中使用,进程将很快耗尽所有可用的内存。相反,我们需要一个能够有效利用堆空间,并且只在必要时请求更多内存的分配器。
什么是放置策略?
在程序执行期间,内存被分配和释放,因此堆内存中会有间隙(空洞),可以重新用于未来的内存请求。内存分配器需要跟踪堆的哪些部分当前被分配,哪些部分是可用的。
假设我们当前的堆大小是 64K,尽管并非所有都在使用,因为一些先前通过程序释放的 malloc 内存已经被释放了:
16KB free | 10KB allocated | 1KB free | 1KB allocated | 30KB free | 4KB allocated | 2KB free |
如果执行一个新的 2KB 的 malloc 请求(malloc(2048)
),malloc 应该在哪里保留内存?它可以使用最后的 2KB 空洞(恰好是完美的大小!),或者它可以分割其他两个空闲空洞中的一个。这些选择代表不同的放置策略。
无论选择哪个空洞,分配器都需要将空洞分成两部分:新分配的空间(将返回给程序)和一个较小的空洞(如果有剩余空间)。
完美拟合策略找到足够大的最小空洞(至少 2KB):
16KB free | 10KB allocated | 1KB free | 1KB allocated | 30KB free | 4KB allocated | 2KB HERE! |
最坏的拟合策略找到足够大的最大空洞(所以将 30KB 的空洞分成两部分):
16KB free | 10KB allocated | 1KB free | 1KB allocated | 2KB HERE! |
28KB free |
4KB allocated | 2KB free |
第一个适合策略找到第一个足够大的可用空洞(将 16KB 的空洞分成两部分):
2KB HERE! |
14KB free |
10KB allocated | 1KB free | 1KB allocated | 30KB free | 4KB allocated | 2KB free |
什么是外部碎片?
在下面的例子中,64KB 的堆内存中,有 17KB 被分配,47KB 是空闲的。然而,最大的可用块只有 30KB,因为我们的可用未分配堆内存被分成了更小的块。
16KB free |
10KB allocated | 1KB free | 1KB allocated | 30KB free | 4KB allocated | 2KB free |
放置策略对外部碎片和性能有什么影响?
不同的策略以不明显的方式影响堆内存的碎片化,这只能通过数学分析或在真实条件下进行仔细模拟(例如模拟数据库或 Web 服务器的内存分配请求)来发现。例如,最佳适配乍看起来似乎是一个很好的策略,但是,如果我们找不到一个完全大小合适的空洞,那么这种放置会产生许多微小的无法使用的空洞,导致高度碎片化。它还需要扫描所有可能的空洞。
首次适配的优势在于它不会评估所有可能的放置,因此更快。
由于最坏适配针对最大的未分配空间,如果需要大量分配,则这是一个不好的选择。
在实践中,首次适配和下次适配(这里没有讨论)通常是常见的放置策略。还有混合方法和许多其他选择(请参见实现内存分配器页面)。
编写堆分配器的挑战是什么?
主要挑战是,
- 需要最小化碎片化(即最大化内存利用)
- 需要高性能
- 繁琐的实现(使用链表和指针算术进行大量指针操作)
一些额外的评论:
碎片化和性能都取决于应用程序的分配配置文件,这可以进行评估但无法预测,并且在实践中,在特定的使用条件下,专用分配器通常可以胜过通用实现。
分配器事先不知道程序的内存分配请求。即使我们知道,这也是著名的 NP 难题背包问题!
如何实现内存分配器?
好问题。实现内存分配器
内存,第二部分:实现内存分配器
内存分配器教程
内存分配器需要跟踪哪些字节当前已分配,哪些可供使用。本页介绍了构建分配器的实现和概念细节,即实现malloc
和free
的实际代码。
这个页面讨论了块的链接 - 我应该为它们分配内存吗?
尽管在概念上我们考虑创建链接列表和块列表,但我们不需要“malloc 内存”来创建它们!相反,我们将整数和指针写入我们已经控制的内存中,以便以后可以一致地从一个地址跳到下一个地址。这些内部信息代表了一些开销。因此,即使我们从系统请求了 1024 KB 的连续内存,我们也无法将所有内存提供给运行的程序。
块思考
我们可以将我们的堆内存看作是一个块的列表,其中每个块都是已分配或未分配的。我们不是存储一个显式的指针列表,而是存储关于块大小的信息作为块的一部分。因此,在概念上,有一个空闲块的列表,但它是隐式的,即以每个块的大小信息的形式存储。
我们可以通过添加块的大小来从一个块导航到下一个块。例如,如果您有一个指向块起始位置的指针p
,那么next_block
将在((char *)p) + *(size_t *) p
,如果您将块的大小以字节存储。将char *
强制转换为确保指针算术是以字节计算的。将size_t *
强制转换为确保在p
处读取的内存是一个大小值,如果p
是void *
或char *
类型,则必须。
调用程序永远不会看到这些值;它们是内存分配器实现的内部值。
例如,假设您的分配器被要求保留 80 字节(malloc(80)
)并需要 8 字节的内部头数据。分配器需要找到至少 88 字节的未分配空间。在更新堆数据后,它将返回一个指向该块的指针。但是,返回的指针并不指向块的起始位置,因为那里存储着内部大小数据!相反,我们将返回块的起始位置+8 字节。在实现中,记住指针算术取决于类型。例如,p += 8
添加的是8 * sizeof(p)
,而不一定是 8 字节!
实现 malloc
最简单的实现使用首次适配:从第一个块开始,假设存在,迭代直到找到表示足够大小的未分配空间的块,或者我们已经检查了所有的块。
如果找不到合适的块,现在是调用 sbrk()
的时候了,以充分扩展堆的大小。一个快速的实现可能会显著地扩展它,这样我们在不久的将来就不需要再请求更多的堆内存。
当找到一个空闲块时,它可能比我们需要的空间大。如果是这样,我们将在我们的隐式列表中创建两个条目。第一个条目是已分配的块,第二个条目是剩余的空间。
有两种简单的方法可以确定块是否正在使用或可用。第一种是将其存储为头信息中的一个字节,以及大小的最低位编码为 1!因此,块大小信息将仅限于偶数值:
// Assumes p is a reasonable pointer type, e.g. 'size_t *'. isallocated = (*p) & 1; realsize = (*p) & ~1; // mask out the lowest bit
对齐和向上取整的考虑
许多体系结构希望多字节原语对齐到 2^n 的某个倍数。例如,通常要求 4 字节类型对齐到 4 字节边界(64 位系统上的 8 字节类型对齐到 8 字节边界)。如果多字节原语未存储在合理的边界上(例如从奇数地址开始),则性能可能会受到显着影响,因为可能需要两个内存读取请求而不是一个。在某些体系结构上,惩罚甚至更大-程序将因总线错误而崩溃。
由于malloc
不知道用户将如何使用分配的内存(双精度数组?字符数组?),因此返回给程序的指针需要对最坏情况进行对齐,这取决于体系结构。
根据 glibc 文档,glibc malloc
使用以下启发式方法:“malloc 给您的块保证对齐,以便它可以容纳任何类型的数据。在 GNU 系统上,大多数系统的地址始终是 8 的倍数,在 64 位系统上是 16 的倍数。”
例如,如果您需要计算需要多少个 16 字节单位,请不要忘记四舍五入-
int s = (requested_bytes + tag_overhead_bytes + 15) / 16
附加的常数确保不完整的单元被四舍五入。请注意,实际代码更有可能使用符号大小,例如sizeof(x) - 1
,而不是编码数值常数 15。
关于内部碎片的说明
内部碎片发生在您提供的块大于其分配大小时。假设我们有一个大小为 16B 的空闲块(不包括元数据)。如果它们分配了 7 个字节,您可能希望将其四舍五入为 16B 并返回整个块。
当您实现合并和分割时(下一节),情况会变得非常阴险。如果您两者都不实现,那么您可能会为 7B 的分配返回一个大小为 64B 的块!这种分配会产生大量的开销,而我们正试图避免这种情况。
实施释放
当调用free
时,我们需要重新应用偏移量以返回到块的“真实”起始位置(记住我们没有给用户指向块实际起始位置的指针?),即我们存储大小信息的位置。
一个天真的实现只会将块标记为未使用。如果我们将块分配状态存储在最低大小位中,那么我们只需要清除该位:
*p = (*p) & ~1; // Clear lowest bit
然而,我们还有更多的工作要做:如果当前块和下一个块(如果存在)都是空闲的,我们需要将这些块合并成一个单一的块。同样,我们也需要检查前一个块。如果存在并表示未分配的内存,那么我们需要将这些块合并成一个单一的大块。
为了能够将一个空闲块与前一个空闲块合并,我们还需要找到前一个块,因此我们也将块的大小存储在块的末尾。这些被称为“边界标记”(参考 Knuth73)。由于块是连续的,一个块的末尾就紧邻着下一个块的开始。因此,当前块(除了第一个块)可以向后查找几个字节以查找前一个块的大小。有了这些信息,您现在可以向后跳转了!
性能
有了上述描述,就可以构建一个内存分配器。它的主要优势是简单性 - 至少与其他分配器相比是简单的!分配内存是最坏情况下的线性时间操作(搜索链表以找到足够大的空闲块),而释放是常数时间(最多只需要将 3 个块合并成一个块)。使用这个分配器,可以尝试不同的放置策略。例如,可以从上次释放块的位置开始搜索,或者从上次分配的位置开始搜索。如果您存储块的指针,您需要非常小心,确保它们始终保持有效(例如,在合并块或其他更改堆结构的 malloc 或 free 调用时)。
显式空闲列表分配器
通过实现一个显式的双向链表可以实现更好的性能。在这种情况下,我们可以立即遍历到下一个空闲块和上一个空闲块。这可以减半搜索时间,因为链表只包括未分配的块。
第二个优势是我们现在对链表的排序有一定的控制。例如,当一个块被释放时,我们可以选择将其插入到链表的开头,而不总是在其邻居之间。这将在下面讨论。
我们在哪里存储链表的指针?一个简单的技巧是意识到块本身没有被使用,并将下一个和上一个指针存储为块的一部分(尽管现在你必须确保空闲块始终足够大,以容纳两个指针)。
我们仍然需要实现边界标签(即使用大小的隐式列表),以便我们可以正确地释放块并将它们与它们的两个邻居合并。因此,显式空闲列表需要更多的代码和复杂性。
使用显式链表,使用快速简单的“查找第一个”算法来查找第一个足够大的链接。然而,由于链接顺序可以被修改,这对应于不同的放置策略。例如,如果链接从大到小维护,那么这将产生“最坏适合”放置策略。
显式链表插入策略
新释放的块可以轻松地插入到两个可能的位置:在开头或按地址顺序(通过使用边界标签首先找到邻居)。
在开头插入会创建一个 LIFO(后进先出)策略:最近释放的空间将被重复使用。研究表明,碎片化比使用地址顺序更严重。
按地址顺序插入(“按地址顺序策略”)插入释放的块,以便以递增的地址顺序访问块。这种策略需要更多的时间来释放块,因为必须使用边界标签(大小数据)来找到下一个和上一个未分配的块。然而,碎片化较少。
案例研究:Buddy Allocator(分离列表的一个示例)
分离的分配器是将堆分成由不同子分配器处理的不同区域的分配器,这取决于分配请求的大小。大小被分组为类(例如,2 的幂),每个大小由不同的子分配器处理,每个大小维护其自己的空闲列表。
这种类型的一个众所周知的分配器是伙伴分配器。我们将讨论二进制伙伴分配器,它将分配分成 2^n(n = 1, 2, 3,…)倍一些基本单位字节数的块,但也存在其他类型(例如,斐波那契分割 - 你能看出为什么它被命名了吗?)。基本概念很简单:如果没有大小为 2^n 的空闲块,就转到下一个级别并窃取该块并将其分成两个。如果两个相邻的相同大小的块变为未分配状态,则它们可以合并成一个两倍大小的单个大块。
伙伴分配器之所以快速,是因为可以从释放的块的地址计算出要合并的相邻块,而不是遍历大小标签。最终的性能通常需要少量的汇编代码来使用专门的 CPU 指令来找到最低的非零位。
伙伴分配器的主要缺点是它们受到内部碎片的影响,因为分配被舍入到最近的块大小。例如,68 字节的分配将需要一个 128 字节的块。
进一步阅读和参考资料
- 参见软件技术和理论计算机科学基础 1999 年会议论文集(Google 图书,第 85 页)
- UIUC 讲座幻灯片(pptx)(pdf)和
- 维基百科的伙伴内存分配页面
其他分配器
有许多其他分配方案。例如SLUB(维基百科)- Linux 内核内部使用的三种分配器之一。
内存,第三部分:破坏堆栈示例
每个线程使用堆栈内存。堆栈“向下增长” - 如果一个函数调用另一个函数,那么堆栈会扩展到更小的内存地址。堆栈内存包括非静态自动(临时)变量,参数值和返回地址。如果缓冲区太小,一些数据(例如来自用户的输入值),那么其他堆栈变量甚至返回地址可能会被覆盖。堆栈内容的精确布局和自动变量的顺序取决于体系结构和编译器。然而,通过一些调查工作,我们可以学会如何故意破坏特定体系结构的堆栈。
下面的示例演示了返回地址存储在堆栈上的方式。对于特定的 32 位体系结构Live Linux Machine,我们确定返回地址存储在自动变量地址的两个指针(8 字节)以上的地址。代码故意改变堆栈值,以便当输入函数返回时,不是继续在主方法内部进行,而是跳转到利用函数。
// Overwrites the return address on the following machine: // http://cs-education.github.io/sys/ #include <stdio.h> #include <stdlib.h> #include <unistd.h> void breakout() { puts("Welcome. Have a shell..."); system("/bin/sh"); } void input() { void *p; printf("Address of stack variable: %p\n", &p); printf("Something that looks like a return address on stack: %p\n", *((&p)+2)); // Let's change it to point to the start of our sneaky function. *((&p)+2) = breakout; } int main() { printf("main() code starts at %p\n",main); input(); while (1) { puts("Hello"); sleep(1); } return 0; }
计算机通常有很多种方法来解决这个问题。
内存复习问题
主题
- 最佳适配
- 最差适配
- 首次适配
- 伙伴分配器
- 内部碎片
- 外部碎片
- sbrk
- 自然对齐
- 边界标签
- 合并
- 分割
- Slab 分配/内存池
问题/练习
- 什么是内部碎片?它何时成为一个问题?
- 什么是外部碎片?它何时成为一个问题?
- 什么是最佳适配策略?它与外部碎片有什么关系?时间复杂度是多少?
- 什么是最差适配策略?它在外部碎片方面有所改善吗?时间复杂度是多少?
- 什么是首次适配放置策略?它在碎片方面稍微好一点,对吧?预期时间复杂度是多少?
- 假设我们正在使用一个新的 64kb 大小的伙伴分配器。它如何分配 1.5kb?
- 当 5 行
sbrk
实现 malloc 时有什么用处? - 自然对齐是什么?
- 什么是合并/分割?它们如何增加/减少碎片?何时可以合并或分割?
- 边界标签是如何工作的?它们如何用于合并或分割?
四、Pthreads 简介
Pthreads,第一部分:介绍
线程简介
什么是线程?
线程是“执行线程”的缩写。它表示 CPU 已经(并将)执行的指令序列。为了记住如何从函数调用返回,并存储自动变量和参数的值,线程使用堆栈。
轻量级进程(LWP)是什么?它与线程有什么关系?
对于所有目的和意图来说,线程就是一个进程(意味着创建线程类似于fork
),只是没有复制,意味着没有写时复制。这允许进程共享相同的地址空间、变量、堆、文件描述符等。
创建线程的实际系统调用类似于fork
;它是clone
。我们不会深入讨论,但您可以阅读man pages,请记住这超出了本课程的直接范围。
在许多情况下,LWP 或线程比 forking 更受欢迎,因为创建它们的开销要少得多。但在某些情况下(特别是 Python 使用这种方式),多进程是使代码更快的方法。
线程的堆栈是如何工作的?
您的主函数(以及您可能调用的其他函数)具有自动变量。我们将使用堆栈将它们存储在内存中,并使用简单指针(“堆栈指针”)跟踪堆栈的大小。如果线程调用另一个函数,我们将将堆栈指针向下移动,以便我们有更多的空间用于参数和自动变量。一旦从函数返回,我们可以将堆栈指针移回到其先前的值。我们在堆栈上保留旧的堆栈指针值的副本!这就是为什么从函数返回非常快速的原因-释放自动变量使用的内存很容易-我们只需要更改堆栈指针。
在多线程程序中,有多个堆栈,但只有一个地址空间。pthread 库分配一些堆栈空间(可以在堆中分配,也可以使用主程序的堆栈的一部分),并使用clone
函数调用在该堆栈地址启动线程。总地址空间可能看起来像这样。
我的进程可以有多少个线程?
您可以在一个进程内运行多个线程。您可以免费获得第一个线程!它运行您在“main”内编写的代码。如果您需要更多线程,可以使用 pthread 库调用pthread_create
创建一个新线程。您需要传递一个指向函数的指针,以便线程知道从哪里开始。
您创建的所有线程都存在于相同的虚拟内存中,因为它们是同一进程的一部分。因此,它们都可以看到堆、全局变量和程序代码等。因此,您可以让两个(或更多)CPU 同时在同一进程中运行您的程序。由操作系统来分配线程给 CPU。如果活动线程多于 CPU,则内核将为线程分配一个 CPU 进行短暂的持续时间(或直到它没有要做的事情),然后将自动切换 CPU 以处理另一个线程。例如,一个 CPU 可能正在处理游戏 AI,而另一个线程正在计算图形输出。
简单用法
Hello world pthread 示例
要使用 pthread,您需要包括pthread.h
,并且需要使用-pthread
(或-lpthread
)编译器选项进行编译。此选项告诉编译器您的程序需要线程支持
要创建线程,请使用函数pthread_create
。此函数有四个参数:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
- 第一个是指向将保存新创建的线程的 ID 的变量的指针。
- 第二个是指向属性的指针,我们可以使用它来调整和调优一些 pthread 的高级特性。
- 第三个是指向我们想要运行的函数的指针
- 第四个是将赋予我们的函数的指针
void *(*start_routine) (void *)
这个参数很难理解!它表示一个接受 void *
指针并返回 void *
指针的指针。它看起来像一个函数声明,只是函数的名称被 (* .... )
包裹起来。
以下是最简单的例子:
#include <stdio.h> #include <pthread.h> // remember to set compilation option -pthread void *busy(void *ptr) { // ptr will point to "Hi" puts("Hello World"); return NULL; } int main() { pthread_t id; pthread_create(&id, NULL, busy, "Hi"); while (1) {} // Loop forever }
如果我们想要等待线程完成,可以使用 pthread_join
。
void *result; pthread_join(id, &result);
在上面的例子中,result
将会是 null
,因为忙碌的函数返回了 null
。我们需要传递结果的地址,因为 pthread_join
将会写入指针的内容。
Pthreads,第二部分:实际应用
更多的 pthread 函数
如何创建一个 pthread?
参见Pthreads Part 1,介绍了 pthread_create
和 pthread_join
如果我调用 pthread_create
两次,我的进程会有多少个堆栈?
你的进程将包含三个堆栈 - 每个线程一个。第一个线程在进程启动时创建,然后你创建了另外两个。实际上可能会有更多的堆栈,但现在让我们忽略这个复杂性。重要的想法是每个线程都需要一个堆栈,因为堆栈包含自动变量和旧的 CPU PC 寄存器,以便在函数完成后可以返回执行调用函数。
一个完整进程和一个线程之间的区别是什么?
此外,与进程不同,同一进程中的线程可以共享相同的全局内存(数据和堆段)。
pthread_cancel
是做什么的?
停止一个线程。请注意,线程可能不会立即停止。例如,当线程进行操作系统调用(例如 write
)时,它可以被终止。
在实践中,pthread_cancel
很少被使用,因为它不给线程一个机会在自身之后进行清理(例如,它可能已经打开了一些文件)。另一种实现方法是使用一个布尔(int)变量,其值用于通知其他线程它们应该完成并进行清理。
exit
和 pthread_exit
之间有什么区别?
exit(42)
退出整个进程并设置进程的退出值。这相当于在主方法中返回 42。进程内的所有线程都会停止。
pthread_exit(void *)
只会停止调用线程,即调用 pthread_exit
后线程永远不会返回。如果没有其他线程在运行,pthread 库将自动完成进程。pthread_exit(...)
等同于从线程函数返回;两者都会完成线程,并为线程设置返回值(void *指针)。
在 main
线程中调用 pthread_exit
是简单程序确保所有线程完成的常见方法。例如,在下面的程序中,myfunc
线程可能没有时间开始。
int main() { pthread_t tid1, tid2; pthread_create(&tid1, NULL, myfunc, "Jabberwocky"); pthread_create(&tid2, NULL, myfunc, "Vorpel"); exit(42); //or return 42; // No code is run after exit }
接下来的两个程序将等待新线程完成-
int main() { pthread_t tid1, tid2; pthread_create(&tid1, NULL, myfunc, "Jabberwocky"); pthread_create(&tid2, NULL, myfunc, "Vorpel"); pthread_exit(NULL); // No code is run after pthread_exit // However process will continue to exist until both threads have finished }
或者,我们可以在每个线程上进行连接(即等待它完成),然后从主函数返回(或调用 exit)。
int main() { pthread_t tid1, tid2; pthread_create(&tid1, NULL, myfunc, "Jabberwocky"); pthread_create(&tid2, NULL, myfunc, "Vorpel"); // wait for both threads to finish : void* result; pthread_join(tid1, &result); pthread_join(tid2, &result); return 42; }
请注意,pthread_exit 版本会创建线程僵尸,但这不是长时间运行的进程,所以我们不在乎。
线程如何被终止?
- 从线程函数返回
- 调用
pthread_exit
- 使用
pthread_cancel
取消线程 - 终止进程(例如 SIGTERM);exit();从
main
返回
pthread_join
的目的是什么?
- 等待线程完成
- 清理线程资源
- 获取线程的返回值
UIUC CS241 讲义:众包系统编程书(4)https://developer.aliyun.com/article/1427162