C++ 学习笔记9
Linux 信号
信号(signal)是软件中断,是进程之间相互传递消息的一种方法,用于通知进程发生了事件,但是,不能给进程传递任何数据。
如:
kill -9 4321
或killall -15 demo
。(killall 是关闭名为 demo 的所有程序。)可以用 kill 或 killall 发送其它信号,如killall -0 demo
信号的作用:当程序运行时,若是突然被终止(kill),则可能造成数据丢失等。因此,可以在接收到 ctrl+C 或 kill 或 killall 信号时,捕捉到信号,并执行保存数据等操作(善后工作)。
示例:
1 |
|
向服务程序发送0的信号,可以检测程序是否存活,如 killall -0 demo
。如果没有提示,则程序在运行,若提示没有进程,则没在运行。
信号的处理
- 对该信号的处理采用系统的默认操作,大部分的信号的默认操作是终止进程。
- 设置信号的处理函数,收到信号后,由该函数来处理。
- 忽略某个信号,对该信号不做任何处理,就像未发生过一样。
signal()函数可以设置程序对信号的处理方式,信号处理函数中只能访问全局对象。
1 | include <signal.h> |
参数signum
表示信号的编号(信号的值)。
参数handler
表示信号的处理方式,有三种情况:
SIG_DFL
:恢复参数signum信号的处理方法为默认行为。一个自定义的处理信号的函数(回调函数 handler),函数的形参是信号的编号。
SIG_IGN
:忽略参数signum所指的信号。
信号的类型
信号名 | 信号值 | 默认处理动作 | 发出信号的原因 |
---|---|---|---|
SIGHUP | 1 | A | 终端挂起或者控制进程终止 |
SIGINT | 2 | A | 键盘中断Ctrl+c |
SIGQUIT | 3 | C | 键盘的退出键被按下 |
SIGILL | 4 | C | 非法指令 |
SIGABRT | 6 | C | 由abort(3)发出的退出指令 |
SIGFPE | 8 | C | 浮点异常 |
SIGKILL | 9 | AEF | 采用kill -9 进程编号 强制杀死程序。 |
SIGSEGV | 11 | CEF | 无效的内存引用(数组越界、操作空指针和野指针等)。 |
SIGPIPE | 13 | A | 管道破裂,写一个没有读端口的管道。 |
SIGALRM | 14 | A | 由闹钟alarm()函数发出的信号。 |
SIGTERM | 15 | A | 采用“kill 进程编号”或“killall 程序名”通知程序。 |
SIGUSR1 | 10 | A | 用户自定义信号1 |
SIGUSR2 | 12 | A | 用户自定义信号2 |
SIGCHLD | 17 | B | 子进程结束信号 |
SIGCONT | 18 | 进程继续(曾被停止的进程) | |
SIGSTOP | 19 | DEF | 终止进程 |
SIGTSTP | 20 | D | 控制终端(tty)上按下停止键 |
SIGTTIN | 21 | D | 后台进程企图从控制终端读 |
SIGTTOU | 22 | D | 后台进程企图从控制终端写 |
其它 | <=64 | A | 自定义信号 |
处理动作一项中的字母含义如下:
A:缺省的动作是终止进程。
B:缺省的动作是忽略此信号,将该信号丢弃,不做处理。
C:缺省的动作是终止进程并进行内核映像转储(core dump)。
D:缺省的动作是停止进程,进入停止状态的程序还能重新继续,一般是在调试的过程中。
E:信号不能被捕获。
F:信号不能被忽略。
alarm
用于定时执行某个任务。
示例:
1 |
|
进程终止
有8种方式可以中止进程。
5种为正常终止:
在
main()
函数用return返回;return表示函数返回,会调用局部对象的析构函数,
main()
函数中的return还会调用全局对象的析构函数。在任意函数中调用
exit()
函数;exit()
表示终止进程,不会调用局部对象的析构函数,只调用全局对象的析构函数。在任意函数中调用
_exit()
或_Exit()
函数;直接退出。既不执行全局对象的析构函数,也不执行局部对象的析构函数。
最后一个线程从其启动例程(线程主函数)用return返回;
在最后一个线程中调用
pthread_exit()
返回;
3种为异常终止:
- 调用abort()函数中止;
- 接收到一个信号;
- 最后一个线程对取消请求做出响应。
进程终止的状态
在main()函数中,return的返回值即终止状态,如果没有return语句或调用exit(),那么该进程的终止状态是0。
在Shell中,查看进程终止的状态:echo $?
正常终止进程的3个函数(exit()
和_Exit()
是由ISO C说明的,_exit()
是由POSIX说明的)。
1 | void exit(int status); |
status也是进程终止后的状态值。
如果进程被异常终止,终止状态为非0。
进程终止函数
进程可以用atexit()
函数登记终止函数(最多32个),这些函数将由exit()
自动调用。
1 | int atexit(void (*function)(void)); |
exit()
调用终止函数的顺序与登记时相反。 进程退出前的收尾工作。
_exit()
不会执行 atexit
的函数。
调用可执行程序
Linux提供了system()函数和exec函数族,在C++程序中,可以执行其它的程序(二进制文件、操作系统命令或Shell脚本)。
system() 函数
system()
函数提供了一种简单的执行程序的方法,把需要执行的程序和参数用一个字符串传给system()
函数就行。本质是 fork 出一个子进程,在子进程执行程序。源码还是使用 execl() 。
1 |
|
返回值:
如果执行的程序不存在,
system()
函数返回非0;如果执行程序成功,并且被执行的程序终止状态是0,
system()
函数返回0;如果执行程序成功,并且被执行的程序终止状态不是0,
system()
函数返回非0。
exec 函数族
exec函数族提供了另一种在进程中调用程序(二进制文件或Shell脚本)的方法。
1 | int execl(const char *path, const char *arg, ...); // 常用 |
注意事项:
如果执行程序失败则直接返回-1,失败原因存于errno中。
新进程的进程编号与原进程相同,但是,新进程取代了原进程的代码段、数据段和堆栈。
如果执行成功则函数不会返回,当在主程序中成功调用exec后,被调用的程序将取代调用者程序,也就是说,exec函数之后的代码都不会被执行。
示例:
1 |
|
创建进程
整个linux系统全部的进程是一个树形结构。
0号进程(系统进程)是所有进程的祖先,它创建了1号和2号进程。
1号进程(systemd)负责执行内核的初始化工作和进行系统配置。
2号进程(kthreadd)负责所有内核线程的调度和管理。
进程标识
每个进程都有一个非负整数表示的唯一的进程ID。 虽然是唯一的,但是进程ID可以复用。当一个进程终止后,其进程ID就成了复用的候选者。Linux采用延迟复用算法,让新建进程的ID不同于最近终止的进程所使用的ID。这样防止了新进程被误认为是使用了同一ID的某个已终止的进程。
1 | pid_t getpid(void); // 获取当前进程的ID。 |
fork() 函数
一个现有的进程可以调用fork()函数创建一个新的进程。
1 | pid_t fork(void); |
由fork()
创建的新进程被称为子进程。子进程是父进程的副本,父进程和子进程都从调用fork()
之后的代码开始执行。
fork()
函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是0,而父进程的返回值则是子进程的进程ID。
子进程获得了父进程数据空间、堆和栈的副本(注意:子进程拥有的是副本,不是和父进程共享)。
注意: 如果在代码中显示
fork()
之前的变量的地址,结果会是相同的。但显示的是虚拟地址(避免fork()
之前创建了变量的引用,导致指向不同地址),实际的物理地址是不同的。
fork()
之后,父进程和子进程的执行顺序是不确定的。
fork 函数的用法
父进程复制自己,然后,父进程和子进程分别执行不同的代码。这种用法在网络服务程序中很常见,父进程等待客户端的连接请求,当请求到达时,父进程调用fork(),让子进程处理些请求,而父进程则继续等待下一个连接请求。
进程要执行另一个程序。这种用法在Shell中很常见,子进程从fork()返回后立即调用exec。(shell 中执行命令的本质就是 fork 一个子进程执行。)
示例:
1 |
|
共享文件
fork()的一个特性是在父进程中打开的文件描述符都会被复制到子进程中,父进程和子进程共享同一个文件偏移量。
即如果是父、子进程关系,则表现为父进程写完接着子进程写(内容可能混合)。
如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步,那么它们的输出可能会相互混合。
示例:
1 |
|
vfork 函数
vfork()函数的调用和返回值与fork()相同,但两者的语义不同。
vfork()函数用于创建一个新进程,而该新进程的目的是exec一个新程序,它不复制父进程的地址空间,因为子进程会立即调用exec,于是也就不会使用父进程的地址空间。如果子进程使用了父进程的地址空间,可能会带来未知的结果。
vfork()和fork()的另一个区别是:vfork()保证子进程先运行,在子进程调用exec或exit()之后父进程才恢复运行。
僵尸进程
如果父进程比子进程先退出,子进程将被1号进程托管(这也是一种让程序在后台运行的方法),如
if(fork()>0) return 0;
如果子进程比父进程先退出,而父进程没有处理子进程退出的信息,那么,子进程将成为僵尸进程。
僵尸进程的危害:
内核为每个子进程保留了一个数据结构,包括进程编号、终止状态、使用CPU时间等。父进程如果处理了子进程退出的信息,内核就会释放这个数据结构,父进程如果没有处理子进程退出的信息,内核就不会释放这个数据结构,子进程的进程编号将一直被占用。系统可用的进程编号是有限的,如果产生了大量的僵尸进程,将因为没有可用的进程编号而导致系统不能产生新的进程。
避免僵尸进程
子进程退出的时候,内核会向父进程发送
SIGCHLD
信号,如果父进程用signal(SIGCHLD,SIG_IGN)
通知内核,表示自己对子进程的退出不感兴趣,那么子进程退出后会立即释放数据结构。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using namespace std;
int main(int argc, char *argv[])
{
signal(SIGCHLD, SIG_IGN); // 代表父进程不关心子进程退出的信息,所以不会产生僵尸进程。
// 子进程退出,如果子进程的信息没有被处理,产生僵尸进程
if(fork()==0) return 0;
while(true)
{
cout << "子进程ing" << endl;
sleep(1);
}
return 0;
}父进程通过
wait()/waitpid()
等函数等待子进程结束,在子进程退出之前,父进程将被阻塞等待。
通过调用wait()
函数来等待子进程结束并获取其退出状态,可以避免僵尸进程的产生。1
2
3
4pid_t wait(int *stat_loc);
pid_t waitpid(pid_t pid, int *stat_loc, int options);
pid_t wait3(int *status, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage);返回值是子进程的编号。
stat_loc
是子进程终止的信息:- 如果是正常终止,宏
WIFEXITED(stat_loc)
返回真,宏WEXITSTATUS(stat_loc)
可获取终止状态; - 如果是异常终止,宏
WTERMSIG(stat_loc)
可获取终止进程的信号。
示例:
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
using namespace std;
int main()
{
if (fork()>0)
{ // 父进程的流程。
int sts;
pid_t pid=wait(&sts);
cout << "已终止的子进程编号是:" << pid << endl;
if (WIFEXITED(sts)) { cout << "子进程是正常退出的,退出状态是:" << WEXITSTATUS(sts) << endl; }
else { cout << "子进程是异常退出的,终止它的信号是:" << WTERMSIG(sts) << endl; }
}
else
{ // 子进程的流程。
//sleep(100);
int *p=0; *p=10; // 异常退出,终止信号11,操作空指针。
exit(1);
}
}- 如果是正常终止,宏
如果父进程很忙,可以捕获
SIGCHLD
信号,在信号处理函数中调用wait()/waitpid()
。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
using namespace std;
void func(int sig) // 子进程退出的信号处理函数。
{
int sts;
pid_t pid=wait(&sts);
cout << "已终止的子进程编号是:" << pid << endl;
if (WIFEXITED(sts)) { cout << "子进程是正常退出的,退出状态是:" << WEXITSTATUS(sts) << endl; }
else { cout << "子进程是异常退出的,终止它的信号是:" << WTERMSIG(sts) << endl; }
}
int main()
{
signal(SIGCHLD,func); // 捕获子进程退出的信号。
if (fork()>0)
{ // 父进程的流程。
while (true)
{
cout << "父进程忙着执行任务。\n";
sleep(1);
}
}
else
{ // 子进程的流程。
sleep(5);
// int *p=0; *p=10;
exit(1);
}
}
多进程与信号
在多进程的服务程序中,如果子进程收到退出信号,子进程自行退出,如果父进程收到退出信号,则应该先向全部的子进程发送退出信号,然后自己再退出。
Linux操作系统提供了kill和killall命令向进程发送信号,在程序中,可以用kill()函数向其它进程发送信号。
函数声明:
1 | int kill(pid_t pid, int sig); |
kill()函数将参数sig指定的信号给参数pid 指定的进程。
参数pid 有几种情况:
pid>0 将信号传给进程号为pid 的进程。
pid=0 将信号传给和当前进程相同进程组的所有进程,常用于父进程给子进程发送信号,注意,发送信号者进程也会收到自己发出的信号。
pid=-1 将信号广播传送给系统内所有的进程,例如系统关机时,会向所有的登录窗口广播关机信息。(少)
sig:准备发送的信号代码,假如其值为0则没有任何信号送出,但是系统会执行错误检查,通常会利用sig值为零来检验某个进程是否仍在运行。
返回值说明: 成功执行时,返回0;失败返回-1,errno被设置。
示例:
1 |
|
共享内存
共享内存的数据结构可以用结构体、数组等c++ 内置的数据类型。不能用 STL 容器,STL 容器会动态的在堆区分配内存,在堆区分配的内存不属于共享内存(运行会出现段错误。)。
POSIX共享内存(
<sys/mman.h>
):现代推荐。System V共享内存(
<sys/shm.h>
):主要为了向后兼容。
多线程共享进程的地址空间,如果多个线程需要访问同一块内存,用全局变量就可以了。
在多进程中,每个进程的地址空间是独立的,不共享的,如果多个进程需要访问同一块内存,不能用全局变量,只能用共享内存。
共享内存(Shared Memory)允许多个进程(不要求进程之间有血缘关系)访问同一块内存空间,是多个进程之间共享和传递数据最高效的方式。进程可以将共享内存连接到它们自己的地址空间中,如果某个进程修改了共享内存中的数据,其它的进程读到的数据也会改变。
共享内存没有提供锁机制,也就是说,在某一个进程对共享内存进行读/写的时候,不会阻止其它进程对它的读/写。如果要对共享内存的读/写加锁,可以使用信号量。
shmget 函数
创建/获取共享内存。
1 |
|
key:共享内存的键值,是一个整数(typedef unsigned int key_t
),一般采用十六进制,例如0x5005,不同共享内存的key不能相同。采用十进制的可读性不好。
size:共享内存的大小,以字节为单位。
shmflg:共享内存的访问权限,与文件的权限一样,例如0666|IPC_CREAT
,0666表示全部用户对它可读写,IPC_CREAT
表示如果共享内存不存在,就创建它。
返回值:成功返回共享内存的id(一个非负的整数),失败返回-1(系统内存不足、没有权限)
Linux 查看和删除系统共享内存
用ipcs -m
可以查看系统的共享内存,包括:键值(key),共享内存id(shmid),拥有者(owner),权限(perms),大小(bytes)。
用ipcrm -m 共享内存id
可以手工删除共享内存。
shmat 函数
该函数用于把共享内存连接到当前进程的地址空间。
返回的共享内存不会调用构造函数,需要手动的初始化。如:
squeue<ElemType, 5> *QQ = (squeue<ElemType, 5>*)shmat(shmid, 0, 0);
1 | void *shmat(int shmid, const void *shmaddr, int shmflg); |
shmid
:由shmget()
函数返回的共享内存标识。shmaddr
:指定共享内存连接到当前进程中的地址位置,通常填0,表示让系统来选择共享内存的地址。shmflg
:标志位,通常填0。
调用成功时返回共享内存起始地址,失败返回(void*)-1
。
shmctl 函数
该函数用于操作共享内存,最常用的操作是删除共享内存。
1 | int shmctl(int shmid, int command, struct shmid_ds *buf); |
shmid
:shmget()
函数返回的共享内存id。
command
:操作共享内存的指令,如果要删除共享内存,填IPC_RMID
。
buf
:操作共享内存的数据结构的地址,如果要删除共享内存,填0。
调用成功时返回0,失败时返回-1。
注意,用root创建的共享内存,不管创建的权限是什么,普通用户无法删除。
示例代码:
1 |
|
POSIX 标准共享内存
写入共享内存的进程
1 |
|
从共享内存读取的进程
1 |
|
信号量
信号量通常与操作系统的信号量概念相关联,用于进程间的同步和通信。信号量可以用于控制对共享资源的访问,确保多个进程之间的互斥或者同步。
查看信号量:ipcs -s
删除信号量:ipcrm sem 信号量id
进程的信号量是一种用于进程间同步和互斥的机制,通常用于解决资源竞争和临界区问题。信号量由一个整数值和一组操作组成,它允许进程在访问共享资源之前进行等待或通知其他进程资源的可用性。
以下是进程的信号量的关键概念和基本操作:
- 信号量值:信号量是一个整数值,通常表示某种资源的可用数量。如果信号量的值大于零,则表示资源可用;如果值等于零,则表示资源已被占用;如果值小于零,则表示有进程正在等待资源。
- 信号量操作:信号量支持两种基本操作:增加(
P
操作)和减少(V
操作)。P
操作(等待):如果信号量的值大于零,则将信号量的值减一,并继续执行;否则,进程将被阻塞,直到信号量的值变为大于零为止。V
操作(释放):将信号量的值加一,表示释放一个资源。
- 初始化信号量:在使用信号量之前,需要初始化信号量的值。可以使用
sem_init()
函数或semctl()
函数来初始化信号量。 - 销毁信号量:在不再需要使用信号量时,需要销毁信号量以释放资源。可以使用
sem_destroy()
函数或semctl()
函数来销毁信号量。 - 进程间同步和互斥:通过使用信号量来实现进程间的同步和互斥,可以确保多个进程之间对共享资源的访问顺序和互斥性。
- 临界区保护:信号量常用于保护临界区,即一次只允许一个进程进入临界区执行,以避免多个进程同时访问共享资源而导致的竞争条件。
PV 操作的全名是 “P 操作” 和 “V 操作”,分别是荷兰语中的 “Proberen” 和 “Verhogen” 的缩写,对应于英文中的 “Test” 和 “Increment”。
- P 操作(也称为等待操作):在信号量的值大于零时,执行 P 操作将信号量的值减一;如果信号量的值等于零,则进程或线程会被阻塞,直到信号量的值变为大于零为止。
- V 操作(也称为释放操作):执行 V 操作将信号量的值加一,表示释放一个资源,通常在释放了一个资源之后调用 V 操作来通知等待该资源的进程或线程。
信号量的作用
互斥访问:确保同一时间只有一个进程(或线程)可以访问某个共享资源。
资源计数:控制有限数量的资源被多个进程(或线程)共享。例如,如果有N个相同的资源可用,信号量可以初始化为N,表示当前有N个资源可用。
信号量使用示例
使用POSIX信号量的简单示例,演示了两个线程如何使用信号量来同步对共享资源的访问。
1 |
|
使用sem_init初始化一个信号量sem,其初始值为1,表示一次只允许一个线程进入临界区。
在每个线程的函数threadFunc中,使用sem_wait等待信号量。如果信号量的值大于0,线程将进入临界区并自动将信号量的值减1。如果信号量的值为0,线程将被阻塞,直到信号量的值变为大于0。
在临界区内,线程对共享变量sharedVar进行操作。
完成操作后,使用sem_post释放信号量,将信号量的值加1,如果有其他线程因等待该信号量而被阻塞,它们中的一个将被唤醒。
最后,使用sem_destroy销毁信号量。
信号量和条件变量
信号量和条件变量都是用于多线程编程中实现线程同步和互斥的机制,但它们之间存在一些区别:
- 用途:
- 信号量通常用于控制对一组资源的访问,可以用来实现生产者-消费者模型、有限缓冲区、读者-写者问题等。
- 条件变量通常用于线程间的条件通知和等待,允许线程在特定条件下等待或被唤醒。
- 基本操作:
- 信号量的基本操作是 P 操作(等待)和 V 操作(释放),其中 P 操作会使线程阻塞直到信号量的值变为大于零,而 V 操作会增加信号量的值。
- 条件变量的基本操作是等待和通知,其中等待操作会使线程等待条件的成立,而通知操作会唤醒等待条件的线程。
- 互斥机制:
- 信号量可以实现互斥,但需要自行编写代码来确保互斥性。
- 条件变量通常与互斥锁一起使用,通过互斥锁来确保临界区的互斥性,并使用条件变量来进行条件等待和通知,以防止竞态条件的发生。
- 复杂性:
- 信号量相对简单,只有两种基本操作,易于理解和使用。
- 条件变量相对复杂一些,需要与互斥锁一起使用,并且需要确保正确的使用条件等待和通知机制,以避免死锁和竞态条件。
- 适用场景:
- 信号量适用于资源数量有限的场景,可以控制对资源的访问顺序和数量。
- 条件变量适用于需要等待特定条件成立的场景,可以实现复杂的线程间协作和同步。
总的来说,信号量和条件变量都是重要的线程同步和互斥机制,各自适用于不同的场景,并且通常可以结合使用以实现更复杂的线程间通信和同步。
消息队列
消息队列是一种进程间通信(IPC)机制,允许一个或多个进程向队列写入消息,而一个或多个进程则可以读取这些消息。消息队列提供了一种异步通信方式,使得发送者和接收者不需要同时交互,它们可以独立地发送和接收消息。
如果消息队列(特别是指System V消息队列和POSIX消息队列)在Linux中创建后没有被明确删除,它们会持久存在于系统中,直到系统重启或者被显式删除。这意味着,即使创建消息队列的程序已经终止,消息队列及其内容仍然存在于系统中。
消息队列的作用
- 解耦进程:消息队列使得进程间的通信不直接依赖于彼此,从而降低了系统各部分之间的耦合度。
- 异步通信:发送者可以继续其处理,而不需要等待接收者立即处理消息。
- 负载均衡:当有多个接收者时,消息队列可以平衡负载,将消息分配给不同的接收者处理。
- 容错性:如果接收者处理能力下降,消息可以在队列中等待,直到可以被处理。
- 顺序保证:大多数消息队列保证了至少在特定条件下消息的顺序性。
消息队列使用示例
这个示例中,发送进程创建(如果不存在)并打开一个名为/testQueue的消息队列,然后发送一条消息。接收进程打开同一个消息队列,接收消息,并在完成后删除消息队列。注意,这两个进程可以独立运行,接收进程不需要在发送进程发送消息时处于运行状态,消息会在队列中等待直到被接收。
发送消息的进程
1 |
|
接收消息的进程
1 |
|
- 在实际使用中,需要确保发送和接收进程对消息队列的权限和属性设置一致。
- POSIX消息队列需要在支持POSIX消息队列的系统上运行,且可能需要相应的库支持。
- 示例中的错误处理非常基础,实际应用中可能需要更详细的错误处理逻辑。
UNIX 环境高级编程
推荐书籍:UNIX 环境高级编程(第3版)人民邮电出版社
项目常用:目录、文件、时间、进程、进程通讯、线程、线程同步。