在前面的章节中,我们考虑了创建嵌入式 Linux 平台的各个方面。 现在,是时候开始研究如何使用该平台来创建工作设备了。 在本章中,我将讨论 Linux 进程模型的含义,以及它是如何包含多线程程序的。 我将研究使用单线程和多线程进程的优缺点,以及进程和协程之间的异步消息传递。 最后,我将讨论调度,并区分分时和实时调度策略。
虽然这些主题不是特定于嵌入式计算的,但对于任何嵌入式设备的设计人员来说,了解这些主题是很重要的。 关于这个主题有很多很好的参考资料,我将在本章的末尾列出其中一些,但总的来说,它们没有考虑嵌入式用例。 因此,我将把重点放在概念和设计决策上,而不是函数调用和代码上。
在本章中,我们将介绍以下主题:
- 进程还是线程?
- 流程
- 丝线
- ZeroMQ
- 时间安排 / 节目安排 / 文物列名保护
我们开始吧!
要按照本章中的示例操作,请确保您的基于 Linux 的主机系统上安装了以下软件:
- Python:Python3 解释器和标准库
- Miniconda:
conda
包和虚拟 环境管理器的最小安装程序
有关如何安装 Miniconda 的说明,请参阅第 16 章,Packaging Python中关于conda
的部分(如果您尚未安装 Miniconda)。 本章的练习还需要 GCC C 编译器和 GNUmake
,但这些工具已经随大多数 Linux 发行版一起提供了。
本章的所有代码都可以在本书 GitHub 存储库的Chapter17
文件夹中找到:https://github.com/PacktPublishing/Mastering-Embedded-Linux-Programming-Third-Edition。
许多熟悉实时操作系统(RTOS)的嵌入式开发人员认为 Unix 进程模型很麻烦。 另一方面,他们看到了 RTOS 任务和 Linux 线程之间的相似之处,而且他们倾向于使用 RTOS 任务到线程的一对一映射来转移现有的设计。 我曾多次看到这样的设计,在这些设计中,整个应用由一个包含 40 个或更多线程的进程实现。 我想花点时间考虑一下这是不是一个好主意。 让我们从一些定义开始。
进程是内存地址空间和执行线程,如下图所示。 地址空间是该进程的私有地址空间,因此运行在不同进程中的线程不能访问它。 这种内存分离是由内核中的内存管理子系统创建的,它为每个进程保存一个内存页面映射,并在每个上下文切换上对内存管理单元进行重新编程。 我将在第 18 章,管理内存中详细描述这是如何工作的。 地址空间的一部分被映射到一个文件,该文件包含程序正在运行的代码和静态数据,如下所示:

图 17.1-流程
当程序运行时,它将分配堆栈空间、堆内存、文件引用等资源。 当进程终止时,系统将回收这些资源:释放所有内存并关闭所有文件描述符。
进程可以使用进程间通信(IPC)相互通信,例如本地套接字。 稍后我将讨论 IPC。
线程是进程内执行的线程。 所有进程都从一个运行main()
函数的线程开始,称为主线程。 例如,您可以使用pthread_create(3)
POSIX 函数创建其他线程,这会导致多个线程在同一地址空间中执行,如下图所示:
图 17.2-多线程
处于同一进程中的线程彼此共享资源。 它们可以读写相同的内存并使用相同的文件描述符。 只要处理好同步和锁定问题,线程之间的通信就很容易。
因此,根据这些简单的细节,您可以想象一个假想系统的两种极端设计,其中有 40 个 RTOS 任务被移植到 Linux 上。
您可以将任务映射到进程,并让 40 个单独的程序通过 IPC 进行通信,例如,消息通过套接字发送。 您将极大地减少内存损坏问题,因为每个进程中运行的主线程都受到保护,不受其他线程的影响,并且您将减少资源泄漏,因为每个进程在退出后都会被清理。 然而,进程之间的消息接口相当复杂,在一组进程之间存在紧密协作的情况下,消息的数量可能会很大,并成为系统性能的限制因素。 此外,这 40 个进程中的任何一个都可能终止,可能是因为错误导致它崩溃,而让其他 39 个进程继续运行。 每个进程都必须处理其邻居不再运行并正常恢复的事实。
在另一个极端,您可以将任务映射到线程,并将系统实现为包含 40 个线程的单个进程。 协作变得容易得多,因为它们共享相同的地址空间和文件描述符。 减少或消除了发送消息的开销,线程之间的上下文切换比进程之间的上下文切换更快。 缺点是您引入了一个任务损坏另一个任务堆或堆栈的可能性。 如果任何线程遇到致命错误,整个进程将终止,所有线程都将随之终止。 最后,调试复杂的多线程进程可能是一场噩梦。
你应该得出的结论是,这两种设计都不是理想的,而且还有更好的做事方式。 但在我们谈到这一点之前,我将更深入地研究 API 以及进程和线程的行为。
进程保存线程可以在其中运行的环境:它保存内存映射、文件描述符、用户和组 ID 等。 第一个进程是init
进程,它由内核在引导期间创建,其 PID 为 1。 此后,通过在称为分叉的操作中复制来创建进程。
创建进程的 POSIX 函数是fork(2)
。 这是一个奇怪的函数,因为对于每个成功的调用,都有两个返回:一个在进行调用的进程中,称为父,另一个在新创建的进程中,称为子,如下图所示:
图 17.3-分叉
在调用之后,子进程立即是父进程的完全副本:它具有相同的堆栈、相同的堆、相同的文件描述符,并且执行相同的代码行-fork
后面的那行代码。 程序员区分它们的唯一方法是查看fork
的返回值:子对象的返回值是零,父对象的返回值是大于零。 实际上,返回给父进程的值是新创建的子进程的 PID。 还有第三种可能性,即返回值为负,这意味着fork
调用失败,仍然只有一个进程。
虽然这两个进程基本相同,但它们位于不同的地址空间中。 其中一方对变量所做的更改将不会被另一方看到。 在幕后,内核不会制作父内存的物理副本,这将是一个相当慢的操作,并且不必要地消耗内存。 相反,存储器是共享的,但用写入时复制(COW)标记为。 如果父进程或子进程修改了该内存,内核将创建一个副本,然后写入该副本。 这使得它成为一个高效的派生函数,还保留了进程地址空间的逻辑分隔。 我将在第 18 章,管理内存中讨论 COW。
现在,让我们学习如何终止进程。
可以通过调用exit(3)
函数或非自愿地通过接收未处理的信号来自动停止进程。 有一个信号(特别是SIGKILL
)无法处理,因此它总是会终止进程。 在所有情况下,终止进程都会停止所有线程,关闭所有文件描述符,并释放所有内存。 系统向父系统发送信号SIGCHLD
,以便它知道这已经发生。
进程有一个返回值,如果它正常终止,则由exit
的参数组成,如果它被终止,则由信号号组成。 它的主要用途是在 shell 脚本中:它允许您测试程序的返回值。 按照惯例,0
表示成功,任何其他值表示某种类型的失败。
父级可以使用wait(2)
或waitpid(2)
函数收集返回值。 这就产生了一个问题:在子级终止和其父级收集返回值之间会有延迟。 在此期间,返回值必须存储在某个地方,并且现在已死的进程的 PID 号不能重用。 这种状态下的进程称为僵尸,在ps
和top
命令中,显示为state Z
。 只要家长在接到孩子终止的通知时呼叫wait
或waitpid
(通过SIGCHLD
信号;有关处理信号的详细信息,请参阅 Robert Love 和 O‘Reilly Media 的Linux System Programming或 Michael Kerrisk 的The Linux Programming Interface,No Stack Press)。 通常,僵尸存在的时间太短,无法出现在进程列表中。 如果父进程无法收集返回值,它们将成为问题,因为最终将没有足够的资源来创建更多进程。
MELP/Chapter17/fork-demo
中的程序说明了进程创建
和终止:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
int pid;
int status;
pid = fork();
if (pid == 0) {
printf("I am the child, PID %d\n", getpid());
sleep(10);
exit(42);
} else if (pid > 0) {
printf("I am the parent, PID %d\n", getpid());
wait(&status);
printf("Child terminated, status %d\n", WEXITSTATUS(status));
} else
perror("fork:");
return 0;
}
wait
函数会一直阻塞,直到子进程退出并存储退出状态。 当您运行它时,您将看到如下所示:
I am the parent, PID 13851
I am the child, PID 13852
Child terminated with status 42
子进程继承父进程的大部分属性,包括用户和组 ID、所有打开的文件描述符、信号处理和调度特征。
函数fork
创建正在运行的程序的副本,但它不运行不同的程序。 为此,您需要exec
函数之一:
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
..., char *const envp[]);
每个文件都有一个指向要加载和运行的程序文件的路径。 如果函数成功,内核将丢弃当前进程的所有资源,包括内存和文件描述符,并将内存分配给正在加载的新程序。 当调用exec*
的线程返回时,它不会返回到调用后的代码行,而是返回到新程序的main()
函数。 在MELP/Chapter17/exec-demo
中有一个命令启动器的例子:它会提示输入一个命令,比如/bin/ls
,然后派生并执行您输入的字符串。 以下是代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
char command_str[128];
int pid;
int child_status;
int wait_for = 1;
while (1) {
printf("sh> ");
scanf("%s", command_str);
pid = fork();
if (pid == 0) {
/* child */
printf("cmd '%s'\n", command_str);
execl(command_str, command_str, (char *)NULL);
/* We should not return from execl, so only get
to this line if it failed */
perror("exec");
exit(1);
}
if (wait_for) {
waitpid(pid, &child_status, 0);
printf("Done, status %d\n", child_status);
}
}
return 0;
}
以下是您在运行它时将看到的内容:
# ./exec-demo
sh> /bin/ls
cmd '/bin/ls'
bin etc lost+found proc sys var
boot home media run tmp
dev lib mnt sbin usr
Done, status 0
sh>
您可以通过键入Ctrl+C来终止程序。
一个函数复制现有进程,而另一个函数丢弃其资源并将不同的程序加载到内存中,这似乎很奇怪,特别是因为 fork 后面几乎紧跟着一个exec
函数是很常见的。 大多数操作系统将这两个操作合并到单个调用中。
然而,这也有明显的优势。 例如,它使得在 shell 中实现重定向和管道非常容易。 假设您想要获得一个目录列表。 以下是事件的顺序:
- 您可以在 shell 提示符中键入
ls
。 - 贝壳会分叉出一份自身的副本。
- 这孩子执行
/bin/ls
。 ls
程序将目录列表打印到附加到终端的stdout
(文件描述符1
)。 您将看到目录列表。ls
程序终止,外壳重新获得控制权。
现在,假设您希望通过使用*>*字符重定向输出来将目录清单写入文件。 现在,顺序如下:
-
您可以键入
ls > listing.txt
。 -
贝壳会分叉出一份自身的副本。
-
子级打开并截断
listing.txt
文件,并使用dup2(2)
在文件描述符1
上复制该文件的文件描述符(stdout
)。 -
这孩子执行
/bin/ls
。 -
程序像以前一样打印列表,但这一次,它将写入
listing.txt
。 -
The
ls
program terminates, and the shell regains control.重要注
在步骤 3中有机会在执行程序之前修改子进程的环境。
ls
程序不需要知道它写入的是文件而不是终端。 可以将stdout
连接到管道,而不是文件,以便仍未更改的ls
程序可以将输出发送到另一个程序。 正如 Eric Steven Raymond 和 Addison Wesley 在The Art of Unix Programming中所描述的那样,这是将许多小组件组合在一起的 Unix 哲学的一部分,特别是在管道、重定向和过滤器部分。
到目前为止,我们在本节中看到的程序都在前台运行。 但是,如果程序在后台运行,等待事情发生,情况会怎样呢? 我们来看看。
我们已经在几个地方遇到了个守护进程。 守护进程是在后台运行的进程,由init
进程拥有,并且不连接到控制终端。 创建守护程序的步骤如下:
- 调用
fork
创建一个新进程,在此之后父进程应该退出,从而创建一个孤儿,该孤儿将重新成为init
的父级。 - 子进程调用
setsid(2)
,创建一个它是其唯一成员的新会话和进程组。 确切的细节在这里无关紧要;您可以简单地认为这是将进程与任何控制终端隔离的一种方式。 - 将工作目录更改为
root
目录。 - 关闭所有文件描述符,并将
stdin
、stdout
和stderr
(描述符0
、1
和2
)重定向到/dev/null
,以便没有输入,并且隐藏所有输出。
值得庆幸的是,前面的所有步骤都可以通过一个函数调用来实现;
,即daemon(3)
。
每个进程都是一个内存孤岛。 您可以通过两种方式将信息从一个传递到另一个。 首先,您可以将其从一个地址空间复制到另一个地址空间。 其次,您可以创建一个既可以访问又可以共享数据的内存区。
第一个通常与队列或缓冲区相结合,以便在进程之间传递一系列消息。 这意味着将消息复制两次:第一次复制到等待区域,然后复制到目的地。 套接字、管道和消息队列就是这样的例子。
第二种方法不仅需要一种创建同时映射到两个(或更多)地址空间的内存的方法,而且还需要一种同步对该内存的访问的方法,例如,使用信号量或互斥锁。
POSIX 具有所有这些功能。 有一组较旧的 API 称为system V IPC,它提供消息队列、共享内存和信号量,但不如 POSIX 等价物灵活,因此我不在这里介绍它们。 svipc(7)
上的手册页概述了这些功能,Michael Kerrisk 的The Linux Programming Interface和 W.Richard Stevens 的Unix Network Programming,第 2 卷中有更多详细信息。
基于消息的协议通常比共享内存更容易编程和调试,但如果消息较大或消息较多,则速度较慢。
基于消息的 IPC 有几个选项,我将总结如下。 区分其中一个和另一个的属性如下:
- 消息流是单向的还是双向的。
- 数据流是没有消息边界的字节流还是保留边界的离散消息。 在后一种情况下,消息的最大大小很重要。
- 邮件是否标记有优先级。
下表汇总了 FIFO、套接字和消息队列的这些属性:
我们将看到的基于消息的 IPC 的第一种形式是 Unix 套接字。
Unix 套接字满足大多数要求,再加上对套接字 API 的熟悉,是迄今为止最常见的机制。
UNIX 套接字是使用AF_UNIX
地址族创建的,并绑定到路径名。 对套接字的访问由套接字文件的访问权限确定。 与 Internet 套接字一样,套接字类型可以是SOCK_STREAM
或SOCK_DGRAM
,前者提供双向字节流,后者提供具有保留边界的离散消息。 UNIX 套接字数据报是可靠的,这意味着它们不会被丢弃或重新排序。 数据报的最大大小取决于系统,可通过/proc/sys/net/core/wmem_max
获得。 通常为 100 KiB 或更高。
UNIX 套接字没有指示消息优先级的机制。
FIFO和命名管道是,只是是相同事物的不同术语。 它们是匿名管道的扩展,当在 shell 中实现管道时,匿名管道用于父进程和子进程之间的通信。
FIFO 是一种特殊类型的文件,由mkfifo(1)
命令创建。 与 Unix 套接字一样,文件访问权限决定谁可以读取和写入。 它们是单向的,这意味着有一个读取器,通常也有一个写入器,尽管可能有几个。 数据是纯字节流,但保证了小于与管道关联的缓冲区的消息的原子性。 换句话说,小于此大小的写入不会被拆分成几个较小的写入,因此只要您端的缓冲区大小足够大,您就可以一次读取整个消息。 在现代内核上,FIFO 缓冲区的默认大小为 64 KiB,可以使用fcntl(2)
和F_SETPIPE_SZ
来增加 FIFO 缓冲区的默认大小,直到/proc/sys/fs/pipe-max-size
中的值(通常为 1 MiB)。 没有优先权的概念。
消息队列由名称标识,该名称必须以正斜杠/
开头,并且只包含一个/
字符:消息队列实际上保存在mqueue
类型的伪文件系统中。 您可以创建一个队列,并通过返回文件描述符的mq_open(3)
获取对现有队列的引用。 每条消息都有一个优先级,消息先根据优先级从队列中读取,然后再根据期限顺序从队列中读取。 消息最长可达/proc/sys/kernel/msgmax
字节。
默认值为 8 KiB,但您可以通过将该值写入/proc/sys/kernel/msgmax
,将其设置为 128 字节到 1 MiB 范围内的任意大小。 由于引用是文件描述符,因此可以使用select(2)
、poll(2)
和其他类似函数来等待队列中的活动。
有关详细信息,请参阅 Linuxmq_overview(7)
手册页。
UNIX 套接字使用最频繁,因为它们提供了所需的所有功能,可能除了消息优先级之外。 它们在大多数操作系统上实现,因此可提供最大的可移植性。
FIFO 使用频率较低,主要是,因为它们缺少与数据报等效的数据。 另一方面,API 非常简单,因为它提供正常的open(2)
、close(2)
、read(2)
和write(2)
文件调用。
消息队列是该组中最不常用的。 内核中的代码路径没有像套接字(网络)和 FIFO(文件系统)调用那样进行优化。
还有更高级别的抽象,如 D-Bus,它们正从主流 Linux 迁移到嵌入式设备。 D-BUS 在表面下使用 Unix 套接字和共享内存。
共享内存消除了在地址空间之间复制数据的需要,但引入了同步访问它的问题。 进程之间的同步通常使用信号量来实现。
要在进程之间共享内存,您必须创建一个新的内存区,然后将其映射到每个想要访问它的进程的地址空间,如下图所示:
图 17.4-POSIX 共享内存
命名 POSIX 共享内存段遵循我们在消息队列中遇到的模式。 段由名称标识,名称以/
字符开头,且恰好有一个这样的字符:
#define SHM_SEGMENT_NAME "/demo-shm"
shm_open(3)
函数接受该名称并返回其文件描述符。 如果它还不存在,并且设置了O_CREAT
标志,则创建一个新段。 最初,它的大小为零。 您可以使用(名称有误)ftruncate(2)
函数将其扩展到所需的大小:
int shm_fd;
struct shared_data *shm_p;
/* Attempt to create the shared memory segment */
shm_fd = shm_open(SHM_SEGMENT_NAME, O_CREAT | O_EXCL | O_RDWR, 0666);
if (shm_fd > 0) {
/* succeeded: expand it to the desired size (Note: dont't
do this every time because ftruncate fills it with zeros) */
printf("Creating shared memory and setting size=%d\n",
SHM_SEGMENT_SIZE);
if (ftruncate(shm_fd, SHM_SEGMENT_SIZE) < 0) {
perror("ftruncate");
exit(1);
}
[…]
} else if (shm_fd == -1 && errno == EEXIST) {
/* Already exists: open again without O_CREAT */
shm_fd = shm_open(SHM_SEGMENT_NAME, O_RDWR, 0);
[…]
}
一旦有了共享内存的描述符,就可以使用mmap(2)
将其映射到进程的地址空间,以便不同进程中的线程可以访问内存:
/* Map the shared memory */
shm_p = mmap(NULL, SHM_SEGMENT_SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED, shm_fd, 0);
MELP/Chapter17/shared-mem-demo
中的程序提供了使用共享内存段在进程之间通信的示例。 下面是main
函数:
static sem_t *demo_sem;
[…]
int main(int argc, char *argv[])
{
char *shm_p;
printf("%s PID=%d\n", argv[0], getpid());
shm_p = get_shared_memory();
while (1) {
printf("Press enter to see the current contents of shm\n");
getchar();
sem_wait(demo_sem);
printf("%s\n", shm_p);
/* Write our signature to the shared memory */
sprintf(shm_p, "Hello from process %d\n", getpid());
sem_post(demo_sem);
}
return 0;
}
该程序使用共享内存段将消息从一个进程传递到另一个进程。 消息为Hello from process string
,后跟其 PID。 get_shared_memory
函数负责创建内存段(如果它不存在),或者如果它存在,则获取它的文件描述符。 它返回指向内存段的指针。 请注意,有一个信号量用于同步对内存的访问,以便一个进程不会覆盖来自另一个进程的消息。
要试用它,您需要在单独的终端会话中运行该程序的两个实例。 在第一个终端中,您将看到类似以下内容:
# ./shared-mem-demo
./shared-mem-demo PID=271
Creating shared memory and setting size=65536
Press enter to see the current contents of shm
Press enter to see the current contents of shm
Hello from process 271
因为这是该程序第一次运行,所以它创建了内存段。 最初,消息区是空的,但在循环运行一次之后,它包含此进程的 PID,即271
。 现在,您可以在另一个终端中运行第二个实例:
# ./shared-mem-demo
./shared-mem-demo PID=279
Press enter to see the current contents of shm
Hello from process 271
Press enter to see the current contents of shm
Hello from process 279
它不创建共享内存段,因为它已经存在,并且它显示它已经包含的消息,这是另一个程序的 PID。 按Enter使其写入自己的 PID,第一个程序将能够看到该 PID。 通过这样做,这两个程序可以相互通信。
POSIX IPC 函数是 POSIX 实时扩展的一部分,因此您需要将它们与librt
链接起来。 奇怪的是,POSIX 信号量是在 POSIX 线程库中实现的,因此您还需要链接到 pthread 库。 因此,当您以 ARM Cortex-A8 SoC 为目标时,编译参数如下:
$ arm-cortex_a8-linux-gnueabihf-gcc shared-mem-demo.c -lrt -pthread \
-o arm-cortex_a8-linux-gnueabihf-gcc
我们对 IPC 方法的调查到此结束。 当我们讨论 ZeroMQ 时,我们将再次讨论基于消息的 IPC。 现在,我们来看看多线程进程。
线程的编程接口是 POSIX 线程 API,它最初是在 IEEE POSIX 1003.1c 标准(1995)中定义的,它是,通常称为pthreads。 它作为libpthread.so
C 库的附加部分实现。 在过去 15 年左右的时间里,有两个个 pthread 实现:LinuxThreads和原生 POSIX 线程库(NPTL)。 后者更符合规范,特别是在信号和进程 ID 的处理方面。 它现在相当占主导地位,但是您可能会遇到一些使用 LinuxThread 的旧版本的uClibc
。
可用于创建线程的函数为pthread_create(3)
:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
它创建一个从start_routine
函数开始的新执行线程,并在pthread_t
中放置一个描述符,该描述符由thread
指向。 它继承调用线程的调度参数,但可以通过传递指向attr
中线程属性的指针来覆盖这些参数。 线程将立即开始执行。
pthread_t
是引用程序内线程的主要方式,但也可以使用命令ps -eLf
从外部查看该线程:
UID PID PPID LWP C NLWP STIME TTY TIME CMD
...
chris 6072 5648 6072 0 3 21:18 pts/0 00:00:00 ./thread-demo
chris 6072 5648 6073 0 3 21:18 pts/0 00:00:00 ./thread-demo
在前面的输出中,thread-demo
程序有两个线程。 正如您所预期的那样,PID
和PPID
列显示它们都属于相同的进程,并且具有相同的父级。 不过,标有LWP
的那一栏很有趣。 LWP代表轻量级进程,在上下文中,它是线程的另一个名称。 该列中的数字是,也称为线程 ID或TID。 在主线程中,TID 与 PID 相同,但对于其他线程,它是一个不同(更高)的值。 您可以在文档中规定必须指定 PID 的地方使用 TID,但请注意,此行为特定于 Linux,不可移植。 下面是一个简单的程序,它演示了线程的生命周期(代码在MELP/Chapter17/thread-demo
中):
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/syscall.h>
static void *thread_fn(void *arg)
{
printf("New thread started, PID %d TID %d\n",
getpid(), (pid_t)syscall(SYS_gettid));
sleep(10);
printf("New thread terminating\n");
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t t;
printf("Main thread, PID %d TID %d\n",
getpid(), (pid_t)syscall(SYS_gettid));
pthread_create(&t, NULL, thread_fn, NULL);
pthread_join(t, NULL);
return 0;
}
注意,在thread_fn
函数中,我使用syscall(SYS_gettid)
检索 TID。 在glibc
2.80 之前,您必须通过syscall
直接调用 Linux,因为gettid()
没有 C 库包装器。
给定内核可以调度的线程总数是有限制的。 这一限制根据系统的大小进行调整,从小型设备上的 1000 个左右到大型嵌入式设备上的数万个。 实际数字在/proc/sys/kernel/threads-max
中提供。 一旦达到此限制,Fork 和pthread_create
将失败。
当发生以下任一情况时,线程将终止:
- 它到达了它的
start_routine
的末尾。 - 它调用
pthread_exit(3)
。 - 它被另一个调用
pthread_cancel(3)
的线程取消。 - 例如,由于线程调用
exit(3)
或进程接收到未处理、屏蔽、 或忽略的信号,包含该线程的进程终止。
请注意,如果多线程程序调用fork
,则新子进程中将只存在进行调用的线程。 Fork 不会复制所有线程。
线程有一个返回值,它是一个空指针。 一个线程可以通过调用pthread_join(2)
等待另一个线程终止并收集其返回值。 正如我们在上一节中提到的,在thread-demo
的代码中有一个这样的示例。 这会产生一个与进程间的僵尸问题非常相似的问题:线程的资源(如堆栈)在另一个线程加入之前无法释放。 如果线程保持未联接,则程序中存在资源泄漏。
对 POSIX 线程的支持是libpthread.so
库中 C 库的一部分。 然而,使用线程构建程序不只是链接库:编译器生成代码的方式必须改变,以确保某些全局变量(如errno
)每个线程有一个实例,而不是整个进程有一个实例。
给小费 / 翻倒 / 倾覆
构建线程化程序时,必须将-pthread
开关添加到编译和链接阶段。 但是,您不必像使用-pthread
那样也使用-lpthread
进行链接阶段。
线程的最大优势是它们共享地址空间,并且可以共享内存变量。 这也是一个很大的缺点,因为它需要同步来保持数据一致性,其方式类似于进程之间共享的内存段,但前提是使用线程,所有内存都是共享的。 实际上,线程可以使用线程本地存储(TLS)创建私有内存,但我不会在这里介绍这一点。
pthreads
接口提供了实现同步所需的基础:互斥和条件变量。 如果你想要更复杂的结构,你必须自己建造。
值得注意的是,我们前面描述的所有 IPC 方法-即套接字、管道和消息队列-在同一进程中的线程之间工作得同样好。
要编写健壮的程序,需要使用互斥锁保护每个共享资源,并确保读取或写入资源的每个代码路径都首先锁定了互斥锁。 如果你始终如一地应用这条规则,大多数问题都应该得到解决。 剩下的那些与互斥锁的基本行为相关。 我将在这里简要地列出它们,但不会太详细:
- 死锁:当互斥锁被永久锁定时会发生这种情况。 一种经典的情况是致命拥抱,其中两个线程各需要两个互斥锁,并且设法锁定了其中一个而没有锁定另一个。 每个线程都阻塞,等待另一个线程拥有的锁,因此它们保持原样。 避免致命拥抱问题的一条简单规则是确保互斥锁始终以相同的顺序锁定。 其他解决方案包括超时和退避时段。
- 优先级反转:等待互斥锁导致的延迟可能会导致实时线程错过最后期限。 优先级反转的具体情况发生在高优先级线程被阻塞,等待由低优先级线程锁定的互斥体时。 如果低优先级线程被其他中等优先级线程抢占,则高优先级线程将被迫等待无限的时间长度。 存在称为优先级继承和优先级上限的互斥协议,它们解决了问题,但代价是每次锁定和解锁调用都会在内核中产生更大的处理开销。
- 性能差:Mutex 给代码带来的开销最小,只要线程在大部分时间内不必阻塞它们。 但是,如果您的设计中有许多线程都需要的资源,那么争用比率就会变得很大。 这通常是一个设计问题,可以使用更细粒度的锁定或不同的算法来解决。
Mutex 并不是线程之间同步的唯一方式。 在介绍 POSIX 共享内存时,我们见证了两个进程如何使用信号量相互通知对方。 线程具有类似的结构。
协作的线程需要能够相互提醒某些事情发生了变化,需要关注。 这称为条件,通过条件变量或condvar发送警报。
条件就是您可以测试以给出true
或false
结果的东西。 一个简单的例子是包含零个或一些项的缓冲区。 一个线程从缓冲区中取出项目,并在它为空时休眠。 另一个线程将项放入缓冲区,并通知另一个线程它已经这样做了,因为另一个线程正在等待的条件已经改变。 如果它在睡觉,它需要醒来,做点什么。 唯一的复杂性是,根据定义,该条件是共享资源,因此必须由互斥保护。
下面是一个包含两个线程的简单程序。 第一个是生产者:它每秒唤醒一次,并将一些数据放入全局变量中,然后发出信号表示发生了变化。 第二个线程是消费者:它等待条件变量,并在每次唤醒时测试条件(缓冲区中有一个非零长度的字符串)。 您可以在MELP/Chapter17/condvar-demo
中找到代码:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
char g_data[128];
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutx = PTHREAD_MUTEX_INITIALIZER;
void *consumer(void *arg)
{
while (1) {
pthread_mutex_lock(&mutx);
while (strlen(g_data) == 0)
pthread_cond_wait(&cv, &mutx);
/* Got data */
printf("%s\n", g_data);
/* Truncate to null string again */
g_data[0] = 0;
pthread_mutex_unlock(&mutx);
}
return NULL;
}
void *producer(void *arg)
{
int i = 0;
while (1) {
sleep(1);
pthread_mutex_lock(&mutx);
sprintf(g_data, "Data item %d", i);
pthread_mutex_unlock(&mutx);
pthread_cond_signal(&cv);
i++;
}
return NULL;
}
请注意,当使用者线程在 condvar 上阻塞时,它是在持有锁定的互斥锁的情况下执行此操作的,这似乎是生产者线程下次尝试更新条件时死锁的秘诀。 为了避免这种情况,pthread_condwait(3)
在线程被阻塞后解锁互斥锁,然后在唤醒它并从等待中返回之前再次锁定它。
既然我们已经介绍了进程和线程的基础知识以及它们的通信方式,现在是时候看看我们可以对它们做些什么了。
以下是我在构建系统时使用的一些规则:
- 规则 1:将具有大量交互的任务放在一起:通过将互操作线程紧密地放在一个进程中,将开销降至最低,这一点很重要。
- 规则 2:不要把所有线程放在一个篮子里:另一方面,出于弹性和模块化的考虑,尽量将交互有限的组件放在单独的进程中。
- 规则 3:不要在同一进程中混合关键和非关键线程:这是规则 2的延伸:系统的关键部分(可能是机器控制程序)应该尽可能保持简单,并以比其他部分更严格的方式编写。 即使其他进程失败,它也必须能够继续运行。 如果您有实时线程,根据定义,它们必须是关键的,并且应该自己进入进程。
- 规则 4:线程不应该太亲密:编写多线程程序时的一个诱惑是在线程之间混合代码和变量,因为这是一个多功能一体的程序,很容易做到。 通过定义良好的交互,使线程保持模块化。
- 规则 5:不要认为线程是免费的:创建额外的线程非常容易,但这是有代价的,尤其是在协调它们的活动所需的额外同步方面。
- 规则 6:线程可以并行工作:线程可以在多核处理器上同时运行,从而提供更高的吞吐量。 如果您的计算任务很大,您可以为每个内核创建一个线程并最大限度地利用硬件。 有一些库可以帮助您做到这一点,例如 OpenMP。 您可能不应该从头开始编写并行编程算法。
Android 的设计就是一个很好的例证。 每个应用都是一个独立的 Linux 进程,有助于模块化内存管理,并确保一个应用崩溃不会影响整个系统。 进程模型还用于访问控制:进程只能访问其 UID 和 GID 允许的文件和资源。 每个进程中都有一组线程。 一个用于管理和更新用户界面,一个用于处理来自操作系统的信号,几个用于管理动态内存分配和释放 Java 对象,以及一个至少由两个线程组成的工作池,用于使用绑定器协议从系统的其他部分接收消息。
总而言之,进程提供弹性,因为每个进程都有一个受保护的内存空间,并且当进程终止时,所有资源(包括内存和文件描述符)都会被释放,从而减少资源泄漏。 另一方面,线程共享资源,可以通过共享变量轻松通信,并可以通过共享对文件和其他资源的访问进行协作。 线程通过工作池和其他抽象实现并行性,这在多核处理器中很有用。
套接字、命名管道和共享内存是进程间通信的方式。 它们充当构成大多数重要应用的消息传递过程的传输层。 并发原语(如互斥锁和条件变量)用于管理共享访问,并协调在同一进程内运行的线程之间的工作。 多线程编程是出了名的困难,套接字和命名管道有它们自己的一组陷阱。 需要更高级别的 API 来抽象异步消息传递的复杂细节。 输入 ZeroMQ。
ZeroMQ是一个异步消息库,其作用类似于并发框架。 它具有进程内、进程间、TCP 和多播传输功能,以及各种编程语言(包括 C、C++、GO 和 Python)的绑定。 这些绑定,加上 ZeroMQ 基于套接字的抽象,允许团队在同一分布式应用中轻松混合编程语言。 库中还内置了对常见消息传递模式(如请求/回复、发布/订阅和并行管道)的支持。 ZeroMQ 中的零代表零成本,而MQ部分代表消息队列。
我们将使用 ZeroMQ 探索基于进程间和进程内消息的通信。 让我们从安装 ZeroMQ for Python 开始。
我们将在中使用 ZeroMQ 的官方 Python 绑定进行以下练习。 我建议在新的虚拟环境中安装此pyzmq
包。 如果您的系统上已经有conda
,那么创建 Python 虚拟环境就很容易。 以下是使用conda
配置必要虚拟环境的步骤:
-
导航到包含示例的
zeromq
目录:(base) $ cd MELP/Chapter17/zeromq
-
创建名为
zeromq
:(base) $ conda create –-name zeromq python=3.9 pyzmq
的新虚拟环境
-
激活您的新虚拟环境:
(base) $ source activate zeromq
-
检查 Python 版本是否为 3.9:
(zeromq) $ python –-version
-
列出您的环境中已安装的软件包:
(zeromq) $ conda list
如果您在包列表中看到pyzmq
及其依赖项,那么您现在就可以运行以下练习了。
我们将从一个简单的回应服务器开始我们对 ZeroMQ 的探索。 服务器期望来自客户端的字符串形式的名称,并回复Hello <name>
。 代码在MELP/Chapter17/zeromq/server.py
中:
import time
import zmq
context = zmq.Context()
socket = context.socket(zmq.REP)
socket.bind("tcp://*:5555")
while True:
# Wait for next request from client
message = socket.recv()
print(f"Received request: {message}")
# Do some 'work'
time.sleep(1)
# Send reply back to client
socket.send(b"Hello {message}")
服务器进程为其响应创建REP
类型的套接字,将该套接字绑定到端口5555
,然后等待消息。 1 秒的休眠用于模拟在接收请求和发回回复之间正在进行的某些工作。
回应客户端的代码在MELP/Chapter17/zeromq/client.py
中:
import zmq
def main(who):
context = zmq.Context()
# Socket to talk to server
print("Connecting to hello echo server…")
socket = context.socket(zmq.REQ)
socket.connect("tcp://localhost:5555")
# Do 5 requests, waiting each time for a response
for request in range(5):
print(f"Sending request {request} …")
socket.send(b"{who}")
# Get the reply.
message = socket.recv()
print(f"Received reply {request} [ {message} ]")
if __name__ == '__main__':
import sys
if len(sys.argv) != 2:
print("usage: client.py <username>")
raise SystemExit
main(sys.argv[1])
客户端进程将用户名作为命令行参数。 客户端为请求创建
类型的套接字,连接到侦听端口5555
的服务器进程,并开始发送包含传入的用户名的消息。 与服务器中的socket.recv()
类似,客户端中的socket.recv()
会阻塞,直到消息到达队列。
要查看 ECHO 服务器和客户端代码的运行情况,请激活您的zeromq
虚拟环境并从MELP/Chapter17/zeromq
目录运行planets.sh
脚本:
(zeromq) $ ./planets.sh
planets.sh
脚本生成三个客户端进程,分别称为Mars
、Jupiter
和Venus
。 我们可以看到,来自三个客户端的请求是交错的,因为每个客户端都在发送下一个请求之前等待来自服务器的回复。 由于每个客户端发送 5 个请求,我们应该总共收到来自服务器的 15 个回复。 使用 ZeroMQ,基于消息的 IPC 非常简单。 现在,让我们使用 Python 的内置asyncio
模块以及 ZeroMQ 来进行进程内消息传递。
asyncio
模块是在版本 3.4 的 Python 中引入的。 它添加了一个可插拔的
事件循环,用于使用协程执行单线程并发代码。 Python 中的协程(也称为,也称为绿色线程)是用async
/await
语法声明的,这是从 C#中采用的。 它们比 POSIX 线程轻得多,工作起来更像是可恢复的函数。 因为协程在事件循环的单线程上下文中操作,所以我们可以结合使用pyzmq
和asyncio
进行进程内基于套接字的消息传递。
以下是取自 https://github.com/zeromq/pyzmq存储库中的一个协程示例的略微修改的版本:
import time
import zmq
from zmq.asyncio import Context, Poller
import asyncio
url = 'inproc://#1'
ctx = Context.instance()
async def receiver():
"""receive messages with polling"""
pull = ctx.socket(zmq.PAIR)
pull.connect(url)
poller = Poller()
poller.register(pull, zmq.POLLIN)
while True:
events = await poller.poll()
if pull in dict(events):
print("recving", events)
msg = await pull.recv_multipart()
print('recvd', msg)
async def sender():
"""send a message every second"""
tic = time.time()
push = ctx.socket(zmq.PAIR)
push.bind(url)
while True:
print("sending")
await push.send_multipart([str(time.time() - tic).encode('ascii')])
await asyncio.sleep(1)
asyncio.get_event_loop().run_until_complete(
asyncio.wait(
[
receiver(),
sender(),
]
)
)
请注意,receiver()
和sender()
协程共享相同的上下文。 套接字的url
部分中指定的inproc
传输方法用于线程间通信,比我们在上一个示例中使用的tcp
传输快得多。 PAIR
模式以独占方式连接两个套接字。 与inproc
传输类似,此消息传递模式只在进程内工作,并用于线程之间的信号传递。 receiver()
或sender()
协同例程都不返回。 asyncio
事件循环在两个协程之间交替,在阻塞或完成 I/O 时暂停和恢复每个协程。
要从活动的zeromq
虚拟环境中运行协程示例,请使用以下命令:
(zeromq) $ python coroutines.py
sender()
将时间戳发送到显示时间戳的receiver()
。 使用Ctrl+C终止该进程。 祝贺你!。 您刚刚看到了没有使用显式线程的进程内异步消息传递。 关于协程和asyncio
,还有很多要说和要学的。 这个例子只是为了让您体验一下在与 ZeroMQ 配合使用时,Python 现在可以实现的功能。 让我们暂时把单线程事件循环放在一边,回到 Linux 这个主题上来。
本章我想要讨论的第二个大主题是调度。 Linux 调度器有一个准备运行的线程队列,它的任务是在 CPU 可用时对它们进行调度。 每个线程都有一个调度策略,该策略可以是分时的,也可以是实时的。 分时线程有一个nicness值,可以增加或减少它们的 CPU 时间。 实时线程具有优先级,因为较高优先级线程将抢占较低优先级线程。 调度程序使用线程,而不是进程。 无论每个线程在哪个进程中运行,都会对其进行调度。
发生以下任一情况时,计划程序都会运行:
- 线程通过调用
sleep()
或另一个阻塞系统调用来阻塞 - 分时线程耗尽了它的时间片
- 中断会导致线程解除阻塞,例如,因为 I/O 完成
有关 Linux 调度器的背景信息,我建议您阅读 Robert Love 在Linux Kernel Development,第 3 版中关于进程调度的章节。
我将调度策略分为两类:分时调度策略和实时调度策略。 分时策略基于公平原则。 它们的设计目的是确保每个线程获得相当数量的处理器时间,并且没有线程可以独占系统。 如果线程运行的时间太长,它会被放在队列的后面,这样其他线程就可以开始运行了。 同时,公平策略需要针对正在做大量工作的线程进行调整,并为它们提供完成工作所需的资源。 分时计划很好,因为它可以自动调整以适应广泛的工作负载。
另一方面,如果你有一个实时的节目,公平是没有帮助的。 在这种情况下,您需要一个确定性的策略,它将至少为您提供最低限度的保证,即您的实时线程将在正确的时间被调度,这样它们就不会错过 的最后期限。 这意味着实时线程必须抢占分时线程。 实时线程还具有静态优先级,当有多个实时线程要同时运行时,调度程序可以使用该优先级在它们之间进行选择。 Linux 实时调度器实现了运行最高优先级实时线程的相当标准的算法。 大多数 RTOS 调度器也是以这种方式编写的。
这两种类型的线程可以共存。 需要确定性调度的线程首先被调度,剩余的时间在分时线程之间分配。
分时策略是为公平而设计的。 从 Linux 2.6.23 开始,使用的调度器是完全公平调度器(CFS)。 它没有使用正常意义上的时间片。 取而代之的是,它计算线程有权运行的时间长度(如果它有合理的 CPU 时间份额),并将其与实际运行的时间进行平衡。 如果它超出了其权限,并且有其他分时线程等待运行,则调度程序将挂起该线程并改为运行等待线程。
分时保单如下:
SCHED_NORMAL
(也称为SCHED_OTHER
):这是默认策略。 绝大多数 Linux 线程都使用此策略。SCHED_BATCH
:这类似于SCHED_NORMAL
,不同之处在于线程的调度粒度更大;也就是说,它们运行的时间更长,但必须等待更长的时间才能再次调度。 这样做的目的是减少用于后台处理(批处理作业)的上下文切换次数,并减少 CPU 缓存搅拌量。SCHED_IDLE
:仅当没有来自任何其他策略的线程可供运行时,才会运行这些线程。 这是可能的最低优先级。
有两对函数可用于获取和设置线程的策略和优先级。 第一对将 PID 作为参数,并影响进程中的主线程:
struct sched_param {
...
int sched_priority;
...
};
int sched_setscheduler(pid_t pid, int policy,
const struct sched_param *param);
int sched_getscheduler(pid_t pid);
第二对对pthread_t
进行操作,并且可以更改进程中其他线程的参数:
int pthread_setschedparam(pthread_t thread, int policy,
const struct sched_param *param);
int pthread_getschedparam(pthread_t thread, int *policy,
struct sched_param *param);
有关线程策略和优先级的更多信息,请参见sched(7)
手册页。 现在我们知道了什么是分时政策和优先事项,让我们来谈谈友善吧。
一些分时的线程比其他线程更重要。 您可以用nice
值来表示这一点,该值将线程的 CPU 权限乘以一个比例因子。 该名称来自函数调用nice(2)
,它从早期就是 Unix 的一部分。 线程通过减少其在系统上的负载或通过增加线程向相反方向移动来变得更好。 值的范围从非常好的19
到非常不好的-20
。 缺省值是0
,这是一个中等不错的值,也就是一般。
可以更改SCHED_NORMAL
和SCHED_BATCH
线程的nice
值。 要减少 NICE,这会增加 CPU 负载,您需要CAP_SYS_NICE
功能,该功能对root
用户可用。 有关功能的更多信息,请参阅capabilities(7)
手册页。
几乎所有关于更改nice
值的函数和命令(nice(2)
以及nice
和renice
命令)的文档都是从进程的角度进行讨论的。 然而,它实际上与线程有关。 正如我们在前面的部分中提到的,您可以使用 TID 代替 PID 来更改单个线程的 nice 值。 nice
的标准描述中的另一个差异是:nice
值被称为线程的优先级(有时被错误地称为进程)。 我认为这是误导,混淆了实时优先的概念,这是完全不同的事情。
实时策略旨在实现确定性。 实时调度程序将始终运行准备运行的最高优先级实时线程。 实时线程总是抢占分时共享线程。 本质上,通过选择实时策略而不是分时策略,您是在说您对此线程的预期调度有深入的了解,并且希望覆盖调度器的内置假设。
有两个实时策略:
SCHED_FIFO
:这是一种Run to Complete算法,这意味着一旦线程开始运行,它将继续运行,直到它被更高优先级的实时线程抢占,在系统调用中被阻塞,或者直到它终止(完成)。SCHED_RR
:这是一种循环算法,如果相同优先级的线程超过其时间片(默认情况下为 100ms),该算法将在这些线程之间循环。 从 Linux3.9 开始,可以通过/proc/sys/kernel/sched_rr_timeslice_ms
控制timeslice
值。 除此之外,它的行为方式与SCHED_FIFO
相同。
每个实时线程的优先级在1
到99
的范围内,其中99
是最高的。
要为线程提供实时策略,您需要CAP_SYS_NICE
,默认情况下它只提供给 root 用户。
实时调度的一个问题,无论是就 Linux 还是其他方面而言,都是因为错误导致线程无限循环而变成计算受限的线程,这将阻止优先级较低的实时线程与所有分时共享线程一起运行。 在这种情况下,系统会变得不稳定,并可能完全锁定。 有几种方法可以防止这种可能性。
首先,从 Linux2.6.25 开始,调度器默认为非实时线程保留了 5%的 CPU 时间,因此即使是失控的实时线程也不能完全停止系统。 它通过两个内核控件进行配置:
/proc/sys/kernel/sched_rt_period_us
/proc/sys/kernel/sched_rt_runtime_us
它们的默认值分别为 1,000,000(1 秒)和 950,000(950 毫秒),这意味着每秒钟保留 50 毫秒用于非实时处理。 如果您希望实时线程能够占用 100%,则将sched_rt_runtime_us
设置为-1
。
第二种选择是使用监视程序(硬件或软件)来监视关键线程的执行,并在它们开始错过最后期限时采取行动。 我在第 13 章,启动-init 程序中提到了看门狗。
实际上,分时策略可以满足大多数计算工作负载。 受 I/O 限制的线程会花费大量时间被阻塞,并且手头总是有一些空闲的权限。 当它们被解锁时,它们将几乎立即被安排。 同时,受 CPU 限制的线程自然会占用任何剩余的 CPU 周期。 正值可以应用于不太重要的线程,负值可以应用于较重要的线程。
当然,这只是一种普通的行为;不能保证永远都是这样。 如果需要更多确定性行为,则需要实时策略。 将线程标记为实时的事情如下所示:
- 它有一个最后期限,必须在此之前生成输出。
- 错过最后期限将损害该系统的有效性。
- 它是事件驱动的。
- 它不受计算机限制。
实时任务的例子包括经典的机械臂伺服控制器、多媒体处理和通信处理。 我将在后面的第 21 章,实时编程中讨论实时系统设计。
选择适用于所有预期的工作负载的实时优先级是一项棘手的业务,也是首先避免实时策略的一个很好的理由。
最广泛使用的选择优先级的程序称为率单调分析(RMA),取自刘和莱兰 1973 年的论文。 它适用于具有周期线程的实时系统,这是一个非常重要的类。 每个线程都有一个周期和一个利用率,这是它将执行的周期的比例。 目标是平衡负载,以便所有线程都能在下一个周期之前完成它们的执行阶段。 RMA 指出,如果发生以下情况,则可以实现此目标:
- 最高优先级授予周期最短的线程。
- 总利用率不到 69%。
总利用率是所有单项利用率的总和。 它还假设线程之间的交互或阻塞在互斥锁上的时间等可以忽略不计。
Linux 和随附的 C 库中内置了悠久的 Unix 传统,它几乎提供了编写稳定、有弹性的嵌入式应用所需的一切。 问题是,对于每一份工作来说,至少有两种方法可以实现你想要的目标。
在本章中,我重点介绍了系统设计的两个方面:将进程划分为独立的进程,每个进程有一个或多个线程来完成任务,以及调度这些线程。 我希望我对这一点有所了解,并为你们进一步研究这些问题提供了基础。
在下一章中,我将研究系统设计的另一个重要方面: 内存管理。
以下资源提供了有关本章中介绍的主题的更多信息:
- The Art of Unix Programming,Eric Steven Raymond 著
- Linux 系统编程,第二版,Robert Love 著
- Linux 内核开发,第三版,Robert Love 著
- The Linux Programming Interface,Michael Kerrisk 著
- UNIX 网络编程,第 2 卷:进程间通信,第二版,W.Richard Stevens 著
- 用 POSIX 线程编程,David R.Butenhof 著
- 硬实时环境中多道程序设计的调度算法,刘春林和 James W.Layland,《ACM 杂志》,1973,第 20 卷,第 1 期,第 46-61 页