Linux系统编程
Linux 系统编程
参考:
码农论坛 Linux 环境高级编程
《unix 环境高级编程》
系统调用
系统调用指的是操作系统提供给用户调用的一组特殊接口(也就是内核提供的函数,操作系统本质是个程序,内核就是操作系统程序的核心部分。)
如何查看系统调用的定义: man 2 write 查看 write 函数。2 是查看系统调用 3 是查看库函数
判断一个函数是系统函数还是库函数的依据:
- 是否访问内核数据结构
- 是否访问外部硬件资源
满足其中一个就是系统函数。
C 标准函数和系统函数调用关系。一个 helloworld 如何打印到屏幕。

系统资源:CPU、内存、总线等操作系统运行需要用到的一系列东西的总称。
时间操作
UNIX 操作系统根据计算机产生的年代把 1970 年 1 月 1 日作为 UNIX 的纪元时间,1970 年 1 月 1 日是时间的中间点,将从 1970 年 1 月 1 日起经过的秒数用一个整数存放。
time_t :用于表示时间类型,它是一个 long 类型的别名,在 <time.h> 文件中定义,表示从 1970 年 1 月 1 日 0 时 0 分 0 秒到现在的秒数。
time()库函数 :用于获取操作系统的当前时间。包含头文件:<time.h>
声明: time_t time(time_t *tloc);
//有两种调用方法:
time_t now=time(0); // 将空地址传递给time()函数,并将time()返回值赋给变量now。
//或
time_t now; time(&now); // 将变量now的地址作为参数传递给time()函数。tm结构体 :
time_t 是一个长整数,不符合人类的使用习惯,需要转换成 tm 结构体,tm 结构体在 <time.h> 中声明,
struct tm
{
int tm_year; // 年份:其值等于实际年份减去1900
int tm_mon; // 月份:取值区间为[0,11],其中0代表一月,11代表12月
int tm_mday; // 日期:一个月中的日期,取值区间为[1,31]
int tm_hour; // 时:取值区间为[0,23]
int tm_min; // 分:取值区间为[0,59]
int tm_sec; // 秒:取值区间为[0,59]
int tm_wday; // 星期:取值区间为[0,6],其中0代表星期天,6代表星期六
int tm_yday; // 从每年的1月1日开始算起的天数:取值区间为[0,365]
int tm_isdst; // 夏令时标识符,该字段意义不大
};localtime()库函数 :用于把 time_t 表示的时间转换为 tm 结构体表示的时间。localtime () 函数不是线程安全的,localtime_r () 是线程安全的。包含头文件:<time.h>
//函数声明
struct tm *localtime(const time_t *timep);
struct tm *localtime_r(const time_t *timep, struct tm *result);示例:
#include <iostream>
#include <time.h> // 时间操作的头文件。
using namespace std;
int main()
{
time_t now=time(0); // 获取当前时间,存放在now中。
cout << "now=" << now << endl; // 显示当前时间,1970年1月1日到现在的秒数。
tm tmnow;
localtime_r(&now,&tmnow); // 把整数的时间转换成tm结构体。
// 根据tm结构体拼接成中国人习惯的字符串格式。
string stime = to_string(tmnow.tm_year+1900)+"-"
+ to_string(tmnow.tm_mon+1)+"-"
+ to_string(tmnow.tm_mday)+" "
+ to_string(tmnow.tm_hour)+":"
+ to_string(tmnow.tm_min)+":"
+ to_string(tmnow.tm_sec);
cout << "stime=" << stime << endl;
}mktime()库函数 :用于把 tm 结构体时间转换为 time_t 时间。包含头文件:<time.h>
函数声明: time_t mktime(struct tm *tm);
该函数主要用于时间的运算,例如:把 2022-03-01 00:00:25 加 30 分钟。
思路:1)解析字符串格式的时间,转换成 tm 结构体;2)用 mktime () 函数把 tm 结构体转换成 time_t 时间;3)把 time_t 时间加 30*60 秒;4)用 localtime_r () 函数把 time_t 时间转换成 tm 结构体;5)把 tm 结构体转换成字符串。
没有示例代码,以后会学到一个封装好上述操作的库。
gettimeofday()函数 :用于获取 1970 年 1 月 1 日到现在的秒和当前秒中已逝去的微秒数,可以用于程序的计时。包含头文件:<sys/time.h>
函数声明:
int gettimeofday(struct timeval *tv, struct timezone *tz);//第2个参数代表时区,传0即可。
struct timeval {
time_t tv_sec; /* 1970-1-1到现在的秒数 */
suseconds_t tv_usec; /* 当前秒中,已逝去的微秒数 */
};
struct timezone { /* 在实际开发中,派不上用场 */
int tz_minuteswest; /* minutes west of Greenwich */
int tz_dsttime; /* type of DST correction */
};示例:
#include <iostream>
#include <sys/time.h> // gettimeofday()需要的头文件。
using namespace std;
int main()
{
timeval start,end;
gettimeofday(&start, 0 ); // 计时开始。
for (int ii=0;ii<1000000000;ii++)
;
gettimeofday(&end, 0 ); // 计时结束。
// 计算消耗的时长。
timeval tv;
tv.tv_usec=end.tv_usec-start.tv_usec;
tv.tv_sec=end.tv_sec-start.tv_sec;
if (tv.tv_usec<0)//微秒相减结果是负数,意思就是结束时间的微秒部分比开始时间小。
{
tv.tv_usec=1000000+tv.tv_usec;
tv.tv_sec--;//向秒数借一位
}
cout << "耗时:" << tv.tv_sec << "秒和" << tv.tv_usec << "微秒。\n";
}sleep和usleep()库函数 :把程序挂起一段时间。包含头文件:<unistd.h>
函数声明:
unsigned int sleep(unsigned int seconds);
int usleep(useconds_t usec);目录操作
- 获取当前的工作目录:
//头文件是unistd
char *getcwd(char *buf, size_t size);
char *get_current_dir_name(void);示例:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
char path1[256]; // linux系统目录的最大长度是255。
getcwd(path1,256);
cout << "path1=" << path1 << endl;
char *path2=get_current_dir_name();
cout << "path2=" << path2 << endl;
free(path2); // 注意释放内存。malloc() new delete
}- 切换工作目录:包含头文件:<unistd.h>
int chdir(const char *path);
返回值:0 - 成功;其它 - 失败(目录不存在或没有权限)。
- 创建目录:包含头文件:<sys/stat.h>
int mkdir(const char *pathname, mode_t mode);
pathname- 目录名mode- 访问权限,如 0755,不要省略前置的 0
返回值:0 - 成功;其它 - 失败(上级目录不存在或没权限)。 比如要创建 /tmp/aaa/bb,必须先有 /tmp/aaa。
- 删除目录:包含头文件: <unistd.h>
int rmdir(const char *path);
path- 目录名
返回值:0 - 成功;其它 - 失败(目录不存在或没有权限)
- 获取目录中的文件列表:文件存放在目录中,在处理文件之前,必须先知道目录中有哪些文件,所以要获取目录中文件的列表。
头文件: #include <dirent.h>
步骤:
//步骤一:用opendir()函数打开目录。
DIR *opendir(const char *pathname);
//成功-返回目录的地址,失败-返回空地址。
//步骤二:用readdir()函数循环的读取目录。
struct dirent *readdir(DIR *dirp);
//成功-返回struct dirent结构体的地址,失败-返回空地址。
//步骤三:用closedir()关闭目录。
int closedir(DIR *dirp);目录指针: DIR *目录指针变量名 ;
每次调用 readdir() ,函数返回 struct dirent 的地址,存放了本次读取到的内容
struct dirent
{
long d_ino; // inode number 索引节点号。
off_t d_off; // offset to this dirent 在目录文件中的偏移。
unsigned short d_reclen; // length of this d_name 文件名长度。
unsigned char d_type; // the type of d_name 文件类型。
char d_name [NAME_MAX+1]; // file name文件名,最长255字符。
};重点关注结构体的 d_name 和 d_type 成员。
d_name- 文件名或目录名。d_type- 文件的类型,有多种取值,最重要的是 8 和 4,8 - 常规文件(A regular file);4 - 子目录(A directory),其它的暂时不关心。注意,``d_name` 的数据类型是字符,不可直接显示。
示例:
#include <iostream>
#include <dirent.h>
using namespace std;
int main(int argc,char *argv[])
{
if (argc != 2) { cout << "Using ./demo 目录名\n"; return -1; }
DIR *dir; // 定义目录指针。
// 打开目录。
if ( (dir=opendir(argv[1])) == nullptr ) return -1;
// 用于存放从目录中读取到的内容。
struct dirent *stdinfo=nullptr;
while (1)
{
// 读取一项内容并显示出来。
if ((stdinfo=readdir(dir)) == nullptr) break;
cout << "文件名=" << stdinfo->d_name << ",文件类型=" << (int)stdinfo->d_type << endl;
}
closedir(dir); // 关闭目录指针。
}注意:readdir 只在当前层级工作。比如读取某个目录,里面有一个文件夹,用 readdir 只会读取到该文件夹,不会读取到文件夹里面的内容。
access()库函数:用于判断当前用户对目录或文件的存取权限。包含头文件:#include <unistd.h>
函数声明: int access(const char *pathname, int mode);
pathname 目录或文件名。
mode 需要判断的存取权限。在头文件 <unistd.h> 中的预定义如下:
#define R_OK 4 // 判断是否有读权限。
#define W_OK 2 // 判断是否有写权限。
#define X_OK 1 // 判断是否有执行权限。
#define F_OK 0 // 判断是否存在。
- 返回值:当 pathname 满足 mode 权限返回 0,不满足返回 - 1,errno 被设置。
在实际开发中,access () 函数主要用于判断目录或文件是否存在。


为什么命令行中 demo 不需要加引号变成字符串。
stat()库函数:包含头文件:#include <sys/stat.h>
函数声明:
int stat(const char *path, struct stat *buf);
stat () 函数获取 path 参数指定目录或文件的详细信息,保存到 buf 结构体中。
返回值:0 - 成功,-1 - 失败,errno 被设置。
stat 结构体
//struct stat结构体用于存放目录或文件的详细信息,如下:
struct stat
{
dev_t st_dev; // 文件的设备编号。
ino_t st_ino; // 文件的i-node。
mode_t st_mode; // 文件的类型和存取的权限。
nlink_t st_nlink; // 连到该文件的硬连接数目,刚建立的文件值为1。
uid_t st_uid; // 文件所有者的用户识别码。
gid_t st_gid; // 文件所有者的组识别码。
dev_t st_rdev; // 若此文件为设备文件,则为其设备编号。
off_t st_size; // 文件的大小,以字节计算。
size_t st_blksize; // I/O 文件系统的I/O 缓冲区大小。
size_t st_blocks; // 占用文件区块的个数。
time_t st_atime; // 文件最近一次被存取或被执行的时间,
// 在用mknod、 utime、read、write 与tructate 时改变。
time_t st_mtime; // 文件最后一次被修改的时间,
// 在用mknod、 utime 和write 时才会改变。
time_t st_ctime; // 最近一次被更改的时间,在文件所有者、组、 权限被更改时更新。
};
//struct stat结构体的成员变量比较多,重点关注st_mode、st_size和st_mtime成员。注意:st_mtime是一个整数表示的时间,需要程序员自己写代码转换格式。
//st_mode成员的取值很多,用以下两个宏来判断:
S_ISREG(st_mode) // 是否为普通文件,如果是,返回真。
S_ISDIR(st_mode) // 是否为目录,如果是,返回真。示例:
#include <stdio.h>
#include <iostream>
#include <cstdio>
#include <sys/stat.h>
#include <unistd.h>
using namespace std;
int main(int argc,char *argv[])
{
if (argc != 2) { cout << "Using:./demo 文件或目录名\n"; return -1; }
struct stat st; // 存放目录或文件详细信息的结构体。
// 获取目录或文件的详细信息
if (stat(argv[1],&st) != 0)
{
cout << "stat(" << argv[1] << "):" << strerror(errno) << endl; return -1;
}
if (S_ISREG(st.st_mode))
cout << argv[1] << "是一个文件(" << "mtime=" << st.st_mtime << ",size=" << st.st_size << ")\n";
if (S_ISDIR(st.st_mode))
cout << argv[1] << "是一个目录(" << "mtime=" << st.st_mtime << ",size=" << st.st_size << ")\n";utime()库函数:用于修改目录或文件的时间。包含头文件:#include <sys/types.h> #include <utime.h>
函数声明:
int utime(const char *filename, const struct utimbuf *times);
- 结构 utimbuf 声明如下:
struct utimbuf
{
time_t actime;
time_t modtime;
};- utime () 函数用来修改参数 filename 的 st_atime 和 st_mtime。如果参数 times 为空地址,则设置为当前时间。
返回值:0 - 成功,-1 - 失败,errno 被设置
这个函数以后还会进行二次封装,因此没有示例。
rename()库函数:用于重命名目录或文件,相当于操作系统的 mv 命令。包含头文件:#include <stdio.h>函数声明:
int rename(const char *oldpath, const char *newpath);参数说明:
oldpath 原目录或文件名。
newpath 目标目录或文件名。
返回值:0 - 成功,-1 - 失败,errno 被设置。
remove()库函数:用于删除目录或文件,相当于操作系统的 rm 命令。包含头文件:#include <stdio.h>函数声明:
int remove(const char *pathname);参数说明:
- pathname 待删除的目录或文件名。
返回值:0 - 成功,-1 - 失败,errno 被设置。
系统错误
在 C++ 程序中,如果调用了库函数,可以通过函数的返回值判断调用是否成功。其实,还有一个整型的全局变量 errno,存放了函数调用过程中产生的错误代码。
如果调用库函数失败,可以通过 errno 的值来查找原因,这也是调试程序的一个重要方法。
errno 在 <errno.h> 中声明。
配合 strerror () 和 perror () 两个库函数,可以查看出错的详细信息。
strerror()库函数 :在 <string.h> 中声明,用于获取错误代码对应的详细信息。
//函数声明
char *strerror(int errnum); // 非线程安全
int strerror_r(int errnum, char *buf, size_t buflen); //线程安全示例:
#include <iostream>
#include <cstring>
#include <cerrno>
#include <sys/stat.h>
using namespace std;
int main()
{
int iret=mkdir("/tmp/aaa",0755);
cout << "iret=" << iret << endl;
cout << errno << ":" << strerror(errno) << endl;
}perror()库函数 :在 <stdio.h> 中声明,用于在控制台显示最近一次系统错误的详细信息,在实际开发中,服务程序在后台运行,通过控制台显示错误信息意义不大。(对调试程序略有帮助)
void perror(const char *s); 这里的参数是自定义的错误信息提示。
注意:
并不是全部的库函数在调用失败时都会设置 errno 的值,以 man 手册为准(一般来说,不属于系统调用的函数不会设置 errno,属于系统调用的函数才会设置 errno)。
errno 的值只有在库函数调用发生错误时才会被设置,当库函数调用成功时,errno 的值不会被修改,不会主动的置为 0。在实际开发中,判断函数执行是否成功还得靠函数的返回值,只有在返回值是失败的情况下,才需要关注 errno 的值。
示例:
#include <iostream> #include <cstring> // strerror()函数需要的头文件。 #include <cerrno> // errno全局变量的头文件。 #include <sys/stat.h> // mkdir()函数需要的头文件。 using namespace std; int main() { int iret=mkdir("/tmp/aaa/bb/cc/dd",0755); if (iret!=0) { cout << "iret=" << iret << endl; cout << errno << ":" << strerror(errno) << endl; perror("调用mkdir(/tmp/aaa/bb/cc/dd)失败"); } iret=mkdir("/tmp/dd",0755); if (ireet!=0) { cout << "iret=" << iret << endl; cout << errno << ":" << strerror(errno) << endl; perror("调用mkdir(/tmp/dd)失败"); } } //错误代码示例: #include <iostream> #include <cstring> // strerror()函数需要的头文件。 #include <cerrno> // errno全局变量的头文件。 #include <sys/stat.h> // mkdir()函数需要的头文件。 using namespace std; int main() { int iret=mkdir("/tmp/aaa/bb/cc/dd",0755); cout << "iret=" << iret << endl; cout << errno << ":" << strerror(errno) << endl; perror("调用mkdir(/tmp/aaa/bb/cc/dd)失败"); iret=mkdir("/tmp/dd",0755); cout << "iret=" << iret << endl; cout << errno << ":" << strerror(errno) << endl; perror("调用mkdir(/tmp/dd)失败"); } //当第一次创建文件夹失败,第二次创建文件夹成功,第二次的errno不会被置为0.
预读入缓输出机制
为什么用库函数 fgetc/fputc 一个字节一个字节的读 / 写文件,比用系统调用 read/write 一个字节一个字节的读 / 写文件要快?

read 是系统调用,每读取一个字节都要进行一次用户态到内核态的上下文切换。在高频调用下,CPU 大量时间浪费在切换模式上,而不是实际的数据处理。
而 fgetc 是 C 标准库提供的函数,它内置了用户级缓冲区(蓝色方框)。它采用‘预读’策略,一次性通过 read 从内核读取一大块数据(如 4KB)到用户态内存中。后续的读取操作直接在内存中进行。本质上, fgetc 是通过减少系统调用的次数来大幅提升性能的。
拓展:shell 中使用 strace 命令跟踪程序执行,查看调用的系统函数。
文件 IO
open、read 和 write 函数
int open(const char *pathname, int flags);
flags 的取值:O_RDONLY、O_WRONLY、O_RDWR O_APPEND(追加)、O_CREAT、O_EXCL(是否存在 已经存在则调用 open 函数失败,不存在则正常创建)、O_TRUNC(截断 将原本的内容清空)、O_NONBLOCK(非阻塞)
open 函数成功返回文件描述符,失败返回 - 1,设置 errno。
int open(const char *pathname, int flags, mode_t mode);
- mode 八进制整型,创建文件的时候需要设置权限。权限同时受 umask 影响。
- 文件权限 = mode&~umask
fd = open("./dict.c",O_RDONLY | O_CREAT | O_TRUNC,0644);
上述语句的含义是,如果存在 dict.c 文件,就以只读的方式打开,并且将原本内容清 0;如果不存在 dict.c 文件,就创建该文件,并指定文件权限为 0644&~umask。
int close(int fd);
open 常见错误:
- 打开文件不存在
- 以写方式打开只读文件
- 以只写方式打开目录(打开目录有专门的函数)
ssize_t read(int fd, void *buf, size_t count);
- count 代表一次能读多少字节,通常设置为 buf 缓冲区的大小
- 成功返回读到的字节数,失败返回 - 1,设置 errno。返回 0 代表读到文件末尾。
- 返回 - 1,并且 errno=EAGIN 或 EWULDBLOCK,说明 read 没有读取失败,而是 read 在以非阻塞方式读一个设备或网络文件,并且文件无数据。
ssize_t write(int fd, const void *buf, size_t count)
- count 代表写入的内容大小
- 成功返回写入的字节数,失败返回 - 1,设置 errno
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <pthread.h>
int main(int argc, char *argv[]) {
// 检查参数数量(实际运行需要传入源文件和目标文件)
if (argc < 3) {
printf("Usage: %s <src> <dest>\n", argv[0]);
return 1;
}
char buf[1024];
int n = 0;
// 以只读方式打开源文件
int fd1 = open(argv[1], O_RDONLY);
if (fd1 == -1) { perror("open src"); return 1; }
// 以读写、创建、截断方式打开目标文件,权限为 0664
int fd2 = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, 0664);
if (fd2 == -1) { perror("open dest"); return 1; }
// 循环读取并写入
while ((n = read(fd1, buf, 1024)) != 0) {
if (n == -1) { perror("read"); break; }
write(fd2, buf, n);
}
close(fd1);
close(fd2);
return 0;
}write 永远是从读写指针 “当前所指的这一位” 开始写入(覆盖)的
文件描述符(File Descriptor)
内核区存在 PCB 进程控制块,是一个结构体,里面有个成员是一个指向文件描述符表的指针。
一个文件描述符指向一个成功打开的文件结构体,结构体中包含文件的各种信息。

文件描述符 0 对应标准输入 STDIN_FILENO;1 对应标准输出 STDOUT_FILENO;2 对应标准错误 STDERR_FILENO
一个进程默认打开文件的个数 1024。命令查看 ulimit -a 查看 open files 对应值。默认为 1024。可以使用 ulimit -n 4096 修改。
cat /proc/sys/fs/file-max 可以查看该电脑最大可以打开的文件个数。受内存大小影响。
打开的文件的 fd 为文件描述符表中可用的最小值。
阻塞和非阻塞
产生阻塞的场景:读设备文件、读网络文件。读常规文件没有阻塞概念。阻塞和非阻塞是文件的属性。
现在明确一下阻塞(Block)这个概念。== 阻塞是否可以理解成等待?== 当进程调用一个阻塞的系统函数时,该进程被置于睡眠(Sleep)状态,这时内核调度其它进程运行,直到该进程等待的事件发生了(比如网络上接收到数据包,或者调用 sleep 指定的睡眠时间到了)它才有可能继续运行。
与睡眠状态相对的是运行(Running)状态,在 Linux 内核中,处于运行状态的进程分为两种情况:
- 正在被调度执行。CPU 处于该进程的上下文环境中,程序计数器(eip)里保存着该进程的指令地址,通用寄存器保存着该进程运算过程的中间结果,正在执行该进程的指令正在读写该进程的地址空间。
- 就绪状态。该进程不需要等待什么事件发生,随时都可以执行,但 CPU 暂时还在执行另一个进程,所以该进程在一个就绪队列中等待被内核调度。系统中可能同时有多个就绪的进程,那么该调度谁执行呢?内核的调度算法是基于优先级和时间片的,而且会根据每个进程的运行情况动态调整它的优先级和时间片,让每个进程都能比较公平地得到机会执行,同时要兼顾用户体验,不能让和用户交互的进程响应太慢
在系统编程这个阶段,我们先来用设备文件来演示阻塞。
/dev/tty 是终端文件,标准输入、标准输出、标准错误都跟该文件有关。
阻塞方式读文件:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(void) {
char buf[10];
int n;
// 默认情况下 STDIN_FILENO是阻塞的
n = read(STDIN_FILENO, buf, 10); //从标准输入中读数据,读到buf中,一次最多读10个字节。
if (n < 0) {
perror("read STDIN_FILENO");
exit(1);
}
write(STDOUT_FILENO, buf, n);
return 0;
}非阻塞方式读文件 + 轮询:
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char buf[10];
int fd, n;
// 以非阻塞方式打开当前终端设备
fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
if (fd < 0) {
perror("open /dev/tty");
exit(1);
}
tryagain:
n = read(fd, buf, 10);//为什么不是从标准输入中读
if (n < 0) {
// EAGAIN 表示当前没数据,如果是其他错误则退出
if (errno != EAGAIN) {
perror("read /dev/tty");
exit(1);
} else {
write(STDOUT_FILENO, "try again\n", strlen("try again\n"));
sleep(2);
goto tryagain;
}
}
write(STDOUT_FILENO, buf, n);
close(fd);
return 0;
}非阻塞方式读文件 + 超时机制:
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MSG_TRY "no data yet, retrying...\n"
#define MSG_TIMEOUT "timeout: no input received\n"
int main(void) {
char buf[10];
int fd, n, i;
fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
if (fd < 0) {
perror("open /dev/tty");
exit(1);
}
printf("open /dev/tty ok... fd=%d\n", fd);
for (i = 0; i < 5; i++) {
n = read(fd, buf, 10);
if (n >= 0) {
break; // 说明读到了东西,跳出循环
}
if (errno != EAGAIN) {
perror("read /dev/tty");
exit(1);
} else {
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
sleep(2);
}
}
if (i == 5) {
write(STDOUT_FILENO, MSG_TIMEOUT, strlen(MSG_TIMEOUT));
} else {
write(STDOUT_FILENO, buf, n);
}
close(fd);
return 0;
}fcntl 改文件属性:
【改变一个已经打开的文件的访问控制属性】
int fcntl(int fd,int cmd, ..../*arg*/);
- F_GETFL 获取文件状态 F_SETFL 设置文件状态
- 返回一个 int 类型,用 flag 接收,flag 实际是个位图。

image-20260307150420732
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MSG_TRY "try again\n"
int main(void)
{
char buf[10];
int flags, n;
// 1. 获取 stdin 属性信息
flags = fcntl(STDIN_FILENO, F_GETFL);//flags是位图
if (flags == -1) {
perror("fcntl error");
exit(1);
}
// 2. 修改标志位为非阻塞
flags |= O_NONBLOCK;
int ret = fcntl(STDIN_FILENO, F_SETFL, flags);//设置文件描述符状态
if (ret == -1) {
perror("fcntl error");
exit(1);
}
tryagain:
// 3. 尝试读取
n = read(STDIN_FILENO, buf, 10);
if (n < 0) {
// 如果 errno 为 EAGAIN,说明当前没有数据可读,而不是真的出错了
if (errno != EAGAIN) {
perror("read /dev/tty");
exit(1);
}
sleep(3);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
goto tryagain;
}
// 4. 读取成功,写到标准输出
write(STDOUT_FILENO, buf, n);
return 0;
}在后续的网络编程中,我们还会继续用到 fcntl 函数。
lseek 函数:
用来修改文件偏移量(读写位置)
off_t lseek(int fd, off_t offset, int whence)
- whence:SEEK_SET SEEK_CUR SEEK_END
- 返回值:从文件起始位置的偏移量 失败返回 - 1,设置 errno
- 文件的读和写使用的是同一个偏移位置。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
int main(void)
{
int fd, n;
char msg[] = "It's a test for lseek\n";
char ch;
// 以读写模式打开文件,如果不存在则创建
fd = open("lseek.txt", O_RDWR | O_CREAT, 0644);
if (fd < 0) {
perror("open lseek.txt error");
exit(1);
}
// 1. 写入数据。写入后,文件指针位于文件末尾
write(fd, msg, strlen(msg));
// 2. 关键步骤:移动文件指针
// 如果注释掉下面这一行,随后的 read 将读不到任何内容,因为指针在结尾
lseek(fd, 0, SEEK_SET); // 将指针移动到文件开头
// 3. 读取并打印到屏幕
while ((n = read(fd, &ch, 1))) {
if (n < 0) {
perror("read error");
exit(1);
}
write(STDOUT_FILENO, &ch, n); // 写出到屏幕
}
close(fd);
return 0;
}小技巧:可以用 lseek 获取文件大小
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
int main(int argc, char *argv[])
{
if (argc < 2) {
printf("Usage: ./get_file_size filename\n");
return 1;
}
// 打开由命令行参数指定的文件
int fd = open(argv[1], O_RDWR);
if (fd == -1) {
perror("open error");
exit(1);
}
// 使用 lseek 获取文件大小
// SEEK_END 表示从文件末尾开始偏移,0 表示偏移量为0
int lenth = lseek(fd, 0, SEEK_END);
printf("file size:%d\n", lenth);
close(fd);
return 0;
}
小技巧:可以用 lseek 函数改变文件大小,要想真正改变文件大小,必须引起 IO 操作。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
int main(int argc, char *argv[])
{
if (argc < 2) {
printf("Usage: ./get_file_size filename\n");
return 1;
}
// 打开由命令行参数指定的文件
int fd = open(argv[1], O_RDWR);
if (fd == -1) {
perror("open error");
exit(1);
}
// 使用 lseek改变文件大小 文件原来大小为a,则lseek改变文件大小为a+111。
int lenth = lseek(fd, 110, SEEK_END);
printf("file size:%d\n", lenth);
write(fd,"A",1);
close(fd);
return 0;
}传入传出参数
传入参数:
- 指针作为函数参数
- 有 const 修饰指针
- 指针指向有效区域、在函数内部做读操作。
传出参数:
- 指针作为函数参数
- 在函数调用前,指针指向的空间可以没有意义
- 在函数内部做写操作
- 函数调用结束后,充当函数返回值
传入传出参数:
- 指针作为函数参数
- 在函数调用前,指针指向的空间有实际意义
- 在函数内部先做读操作、再做写操作
- 函数调用结束后,可以充当函数返回值的功能。
环境变量
比如 PATH SHELL TERM(终端)HOME(用户主目录)
name=value 键值对
PATH :指定可执行文件的搜索路径。ls 命令也是一个程序,执行它不需要提供完整的路径名 /bin/ls,然而通常我们执行当前目录下的程序 a.out 却需要提供完整的路径名./a.out,这是因为 PATH 环境变量的值里面包含了 Is 命令所在的目录 /bin,却不包含 a.out 所在的目录。PATH 环境变量的值可以包含多个目录,用:号隔开。在 Shell 中用 echo 命令可以查看这个环境变量的值:echo $PATH
env 查看所有环境变量
环境变量、main 函数的命令行参数放在 stack 的上面。
文件系统
文件系统是,一组规则,规定对文件的存储及读取的一般方法。文件系统在磁盘格式化过程中指定。常见的文件系统有:fat32 ntfs exfat ext2、ext3、ext4
文件存储
首先先了解文件存储的相关概念:
inode:其本质为结构体,存储文件的属性信息。如:权限、类型、大小、时间、用户、盘块位置…… 也叫作文件属性管理结构,大多数的 inode 都存储在磁盘上。

dentry:目录项,其本质依然是结构体,重要成员变量有两个 {文件名,inode,...},而文件内容 (data) 保存在磁盘盘块中。
一个文件由 dentry 和 inode 组成。
stat 函数和 lstat 函数
获取文件属性
int stat(const char*path, struct stat *buf)
- 第一个参数:文件路径
- 第二个参数:传出参数,原来存储文件属性。
- 成功 --0 失败 ---1,设置 errno
获取文件大小:buf.st_size
获取文件类型和权限:buf.st_mode 可以通过宏进行判断。

stat 函数和 lstat 函数的主要区别在于如何处理符号链接(软链接)
- 如果目标是一个符号链接,stat 会顺着链接找到它指向的原始文件,并返回该原始文件的属性。
- 如果目标是一个符号链接,lstat 会直接返回该链接文件本身的属性(如链接的大小、权限等),而不是它指向的文件。
补充:什么是软链接:相当于 windows 的快捷方式
access 函数
测试指定文件是否拥有某种权限。
int access(const char*pathname, int mode)
- 成功返回 0,失败返回 - 1,设置 errno
- 参数 2:R_OK、W_OK、X_OK F_OK(文件是否存在)
chmod 函数
修改文件的访问权限。
int chmod(const char*path, mode_t mode)
- 成功返回 0,失败返回 - 1,设置 errno
int fchmod(int fd, mode_t mode)
truncate 函数
拓展文件大小
int truncate(const char*path off_t length);
成功返回 0,失败返回 - 1,设置 errno
第二个参数:文件将拓展成该大小
如果原文件大小小于
length,文件会被扩展。中间空出来的部分会被填充为\0(null bytes),这在磁盘上形成了所谓的 “文件空洞”。
int ftruncate(int fd, off_t length);
- 通过文件描述符(fd)来操作,必须先用
open()打开文件并获得写权限。
link 和 unlink 函数
硬链接就像是一个文件的 “别名”。在 Linux 中,每个文件都有一个唯一的 inode 号,硬链接实际上是创建一个新的 dentry,并将其指向已有的 inode 。
软链接创建一个新的文件,其数据块里存放的是目标文件的路径字符串。软链接有自己独立的 inode 号和文件属性。
创建硬链接(目录项):
int link(const char* oldpath, const char* newpath)
删除目录项:
int unlink(const char*pathname)
用 link 函数实现 mv(重命名)命令:先创建一个硬链接,然后删除之前的硬链接。

注意 Linux 下删除文件的机制:不断将 st_nlink -1,直至减到 0 为止。文件没有目录项,并且没有任何进程在使用该文件,此时 inode 中维护的计数器会变成 0,而且操作系统会把 inode 和指向的磁盘区域标记成空闲。但是 inode 里面的东西还是原先的东西,所以可以通过数据恢复工具找到删除的文件。该文件将会被操作系统择机释放。(也就是释放文件所占用的磁盘区域,允许其他数据覆盖该区域。)
因此,我们删除文件,从某种意义上说,只是让文件具备了被释放的条件。
unlink 函数的特征:清除文件时,如果文件的硬链接数到 0 了,没有 dentry 对应,但该文件仍不会马上被释放。要等到所有打开该文件的进程关闭该文件,系统才会挑时间将该文件释放掉。
隐式回收
当进程结束运行时,所有该进程打开的文件会被关闭,申请的内存空间会被释放。系统的这一特性称之为隐式回收系统资源。
目录操作函数
头文件:dirent.h
opendir 函数
DIR* opendir(const char *name)
- 成功返回指向该目录结构体指针,失败返回 NULL,设置 errno
closedir 函数
int closedir(DIR* dirp)
- 失败 - 1,设置 errno;成功返回 0
readdir 函数
struct dirent* readdir(DIR* dirp)
- 成功返回目录项结构体指针;失败返回 NULL,设置 errno
- 需注意返回值,读取数据结束时也返回 NULL 值,所以应借助 errno 进一步加以区分。

image-20260307171210400
文件、目录权限

实现 ls 命令:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dirent.h>
#include <pthread.h>
int main(int argc, char *argv[])
{
if (argc < 2) {
printf("Usage: ./a.out dirname\n");
return 1;
}
DIR *dp; //目录结构体指针
struct dirent *sdp;// 目录项结构体指针
// 打开目录流
dp = opendir(argv[1]);
if (dp == NULL) {
perror("opendir error");
exit(1);
}
// 循环读取目录项
while ((sdp = readdir(dp)) != NULL) {
// 过滤掉以 "." 开头的文件(包括 . 和 ..)
if (sdp->d_name == '.' || sdp->d_name == '..') {
continue;
}
printf("%s\t", sdp->d_name);
}
printf("\n");
closedir(dp);
return 0;
}递归遍历目录代码:查询指定目录,递归列出目录中的文件,同时显示文件大小。
- 判断命令行参数个数 if (argc == 1) 默认查询当前目录./argv [1] 指定目录名
- 判断用户指定的是否是目录 stat 的宏 S_ISDIR () 封装成一个函数
- 读目录 不是文件就递归调用 opendir(先 sprintf 拼接目录访问绝对路径)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
// 函数声明
void isFile(char *name);
void read_dir(char *dir,void (*func)(char*));
// 处理单个文件/目录的逻辑
void isFile(char *name) {
struct stat sb;
// 获取文件属性,判断文件类型
if (stat(name, &sb) == -1) {
perror("stat error");
return;
}
// 如果是目录文件,递归调用 read_dir
if (S_ISDIR(sb.st_mode)) {
read_dir(name);
}
// 如果是普通文件,显示名字和大小
printf("%10s\t%ld\n", name, sb.st_size);
}
// 读取目录内容的逻辑
void read_dir(char *dir) {
char path[256];
DIR *dp;
struct dirent *sdp;
dp = opendir(dir);
if (dp == NULL) {
perror("opendir error");
return;
}
while ((sdp = readdir(dp)) != NULL) {
// 过滤掉 "." 和 ".." 防止死循环
if (strcmp(sdp->d_name, ".") == 0 || strcmp(sdp->d_name, "..") == 0) {
continue;
}
// 拼接路径
sprintf(path, "%s/%s", dir, sdp->d_name);
isFile(path);
}
closedir(dp);
}
int main(int argc, char *argv[]) {
if (argc < 2) { //用户没有指定路径,就默认是当前路径
read_dir(".");
} else {
read_dir(argv[1]);
}
return 0;
}也可以这样使用回调函数:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
// 函数声明
void isFile(char *name);
void read_dir(char *dir);
// 处理单个文件/目录的逻辑
void isFile(char *name) { //回调函数
struct stat sb;
// 获取文件属性,判断文件类型
if (stat(name, &sb) == -1) {
perror("stat error");
return;
}
// 如果是目录文件,递归调用 read_dir
if (S_ISDIR(sb.st_mode)) {
read_dir(name,isFile);
}
// 如果是普通文件,显示名字和大小
printf("%10s\t%ld\n", name, sb.st_size);
}
// 读取目录内容的逻辑
void read_dir(char *dir, void (*func)(char*)) {//用函数指针的方式传入isFile函数
char path[256];
DIR *dp;
struct dirent *sdp;
dp = opendir(dir);
if (dp == NULL) {
perror("opendir error");
return;
}
while ((sdp = readdir(dp)) != NULL) {
// 过滤掉 "." 和 ".." 防止死循环
if (strcmp(sdp->d_name, ".") == 0 || strcmp(sdp->d_name, "..") == 0) {
continue;
}
// 拼接路径
sprintf(path, "%s/%s", dir, sdp->d_name);
func(path);
}
closedir(dp);
}
int main(int argc, char *argv[]) {
if (argc < 2) { //用户没有指定路径,就默认是当前路径
read_dir(".",isFile);
} else {
read_dir(argv[1].isFile);
}
return 0;
}重定向
使用 dup 和 dup2 函数实现重定向。
dup 和 dup2 函数
这两个函数允许你让多个文件描述符指向同一个打开的文件。
头文件: unistd.h
int dup(int oldfd)
- 成功返回新的 fd,失败返回 - 1
dup会返回当前进程中最小的、尚未被使用的文件描述符,并将其指向目标文件。
int dup2(int oldfd, int newfd)
- 它允许你指定新的文件描述符的数值。如果目标描述符已经打开,系统会先自动将其关闭。
- 成功返回 newfd,失败返回 - 1,设置 errno
实现重定向的逻辑:
如果你想把标准输出( STDOUT_FILENO ,即 1 号 fd,1 号 fd 本来指向的是标准输出也就是屏幕)重定向到 fd 所指向的文件,你会这样写: dup2(fd, STDOUT_FILENO); 这表示:“让 1 号描述符不再指向屏幕,而是指向 fd 所对应的文件。”


fcntl 实现 dup 描述符
fcntl 第二个参数传 F_DUPFD 返回一个新的文件描述符;第三个参数传 newfd,如果 newfd 被占用了,就返回可用的最小文件描述符;如果没有被占用,就返回 newfd。

进程
进程地址空间
虚拟内存就是在你电脑的物理内存不够用时把一部分硬盘空间作为内存来使用,这部分硬盘空间就叫作虚拟内存。
Linux 的虚拟地址空间范围为 0~4G,Linux 内核将这 4G 字节的空间分为两部分,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)供内核使用,称为 内核空间 。而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)供各个进程使用,称为 用户空间 。
可以通过系统调用从用户空间进入内核空间。
一个页大小是 4k。
虚拟地址到物理地址转换过程有操作系统和 CPU 共同完成(操作系统为 CPU 设置好页表,CPU 通过 MMU(内存管理单元) 单元进行地址转换)。

不同进程的虚拟内核空间会映射到同一个物理内存。

MMU(内存管理单元)的作用:
- 虚拟内存与物理内存的映射
- 设置内存访问级别
- page 4k
内存访问级别:0 3 0 级供内核使用,3 级供用户程序使用,
PCB 进程控制块
本质是结构体。存储进程的相关信息。
相关信息主要有:
进程 id 文件描述符表 进程工作目录位置 信号相关信息 用户 id 和组 id 进程状态(初始、就绪、运行、挂起、停止)
012 号进程
整个 linux 系统全部的进程是一个树形结构。
0 号进程(系统进程)是所有进程的祖先,它创建了 1 号和 2 号进程。
1 号进程(systemd)负责执行内核的初始化工作和进行系统配置。
2 号进程(kthreadd)负责所有内核线程的调度和管理。
用 pstree 命令可以查看进程树(yum -y install psmisc)。
pstree -p 进程编号
进程终止
有 8 种方式可以中止进程,其中 5 种为正常终止,它们是:
1)在 main () 函数用 return 返回;
2)在任意函数中调用 exit () 函数;
3)在任意函数中调用_exit () 或_Exit () 函数;
4)最后一个线程从其启动例程(线程主函数)用 return 返回;
5)在最后一个线程中调用 pthread_exit () 返回;
异常终止有 3 种方式,它们是:
6)调用 abort () 函数中止;
7)接收到一个信号;
8)最后一个线程对取消请求做出响应。
进程终止状态:
在 main () 函数中,return 的返回值即终止状态,如果没有 return 语句或调用 exit (),那么该进程的终止状态是 0。
在 Shell 中,查看最近一个进程终止的状态:echo $?
正常终止进程的 3 个函数(exit () 和_Exit () 是由 ISO C 说明的,_exit () 是由 POSIX 说明的)。
void exit(int status);
void _exit(int status);
void _Exit(int status);
status 是进程终止的状态。
如果进程被异常终止,终止状态为非 0。 终止状态常用于服务程序的调度、日志和监控。
资源释放的问题:
retun 表示函数返回,会调用局部对象的析构函数,main () 函数中的 return 还会调用全局对象的析构函数。
exit() 表示终止进程,不会调用局部对象的析构函数,只调用全局对象的析构函数。
exit () 会执行清理工作(调用全局对象的析构函数),然后退出,_exit () 和_Exit () 直接退出,不会执行任何清理工作。
进程可以用 atexit() 函数登记终止函数(最多 32 个),这些函数将由 exit() 自动调用。
int atexit(void (*function)(void));
exit () 调用终止函数的顺序与登记时相反。 进程退出前的收尾工作

fork 函数
头文件: #include <unistd.h>
函数原型: pid_t fork(void)
pid_t表示进程 ID,但是为了表示 - 1,他是有符号整型。0 不是有效进程 ID,init 最小,为 1。- 失败返回 - 1;子进程的 fork 函数返回 0,父进程返回子进程的 PID。
- 子进程只执行 fork 函数返回后的语句。
- 注意 fork 之后父进程先执行还是子进程先执行是不确定的,取决于内核使用的调度算法。
- 子进程获得了父进程数据空间、堆和栈的副本(注意:子进程拥有的是副本,不是和父进程共享)。
- 我们在 shell 中每输入一个命令,shell 会调用 fork 函数,让子进程去执行命令。

可以通过下面这张图仔细体会下 fork 函数:

fork 的两种用法:
1)父进程复制自己,然后,父进程和子进程分别执行不同的代码。这种用法在网络服务程序中很常见,父进程等待客户端的连接请求,当请求到达时,父进程调用 fork (),让子进程处理些请求,而父进程则继续等待下一个连接请求。
2)进程要执行另一个程序。这种用法在 Shell 中很常见,子进程从 fork () 返回后立即调用 exec。
示例:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
if (fork()>0)
{ // 父进程将执行这段代码。
while (true)
{
sleep(1);
cout << "父进程运行中...\n";
}
}
else
{ // 子进程将执行这段代码。
sleep(10);
cout << "子进程开始执行任务...\n";
execl("/bin/ls","/bin/ls","-lt","/tmp",0);
cout << "子进程执行任务结束,退出。\n";
}
}fork () 的一个特性是在父进程中打开的文件描述符都会被复制到子进程中,父进程和子进程共享同一个文件偏移量。
如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步,那么它们的输出可能会相互混合。
vfork()函数
vfork () 函数的调用和返回值与 fork () 相同,但两者的语义不同。
vfork () 函数用于创建一个新进程,而该新进程的目的是 exec 一个新程序,它不复制父进程的地址空间,因为子进程会立即调用 exec,于是也就不会使用父进程的地址空间。如果子进程使用了父进程的地址空间,可能会带来未知的结果。
vfork () 和 fork () 的另一个区别是:vfork () 保证子进程先运行,在子进程调用 exec 或 exit () 之后父进程才恢复运行。
getpid 和 getppid
每个进程都有一个非负整数表示的唯一的进程 ID。虽然是唯一的,但是进程 ID 可以复用。当一个进程终止后,其进程 ID 就成了复用的候选者。Linux 采用延迟复用算法,让新建进程的 ID 不同于最近终止的进程所使用的 ID。这样防止了新进程被误认为是使用了同一 ID 的某个已终止的进程。
函数原型:
pid_t getpid(void) pid_t getppid(void) 获取父进程 ID
循环创建 n 个子进程:用 break 消除子进程产生的子进程。

进程共享
父子进程相同的地方:(大前提是刚 fork 完)全局变量、data、text、堆、栈、环境变量、宿主目录位置、进程工作目录位置、信号处理方式。
父子进程不同的地方:进程 id、fork 返回值、各自的父进程、进程创建时间、闹钟、未决信号集。
父子进程共享:文件描述符 mmap 建立的映射区
原则:读时共享、写时复制。

父子进程不共享全局变量。父进程改了全局变量,子进程看不到变化。
调用可执行程序
Linux 提供了 system() 函数和 exec 函数族,在 C++ 程序中,可以在进程中执行其它的程序(二进制文件、操作系统命令或 Shell 脚本)。
exec 函数族
fork 创建子进程后执行的是和父进程相同的程序,子进程往往会调用一种 exec 函数用来执行另一个程序。调用 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,但是进程 id 不变,不会创建新进程。
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[]);在实际开发中,最常用的是 execl () 和 execv (),其它的极少使用。
示例:
#include <iostream>
#include <string.h>
#include <unistd.h>
using namespace std;
int main(int argc,char *argv[])
{
int ret=execl("/bin/ls","/bin/ls","-lt","/tmp",0); // 最后一个参数0不能省略。
cout << "ret=" << ret << endl;
perror("execl");
/*
char *args[10];
args[0]="/bin/ls";
args[1]="-lt";
args[2]="/tmp";
args[3]=0; // 这行代码不能省略。
int ret=execv("/bin/ls",args);
cout << "ret=" << ret << endl;
perror("execv");
*/
}execlp 函数
p 代表的是 PATH 环境变量。
int execlp(const char *file, const char *arg, ...)
- 返回 - 1 说明出错。
- 不出错,则没有返回值。
execl 函数
int execl(cosnt char *path, const char *arg, ...)
exec 函数调用成功不返回,只有失败才返回。所以通常直接在 exec 函数调用后直接调用 perror () 和 exit ()。
只有 execve 是真正的系统调用。其他函数都是库函数,对 execve 函数进行了封装。

练习:将进程信息打印到文件中。
system 函数
system () 函数提供了一种简单的执行程序的方法,把需要执行的程序和参数用一个字符串传给 system () 函数就行了。头文件用 < stdlib.h>
函数的声明:
int system(const char * string);
system () 函数的返回值比较麻烦。
1)如果执行的程序不存在,system () 函数返回非 0;
2)如果执行程序成功,并且被执行的程序终止状态是 0(return 0 或者 exit (0) ),system () 函数返回 0;
3)如果执行程序成功,并且被执行的程序终止状态不是 0,system () 函数返回非 0。
程序正常运行起来,中途被异常终止了,这叫执行程序失败。
示例:

孤儿进程和僵尸进程
孤儿进程:父进程比子进程更早终止,系统会自动给孤儿进程一个 PPID,子进程的父进程成为 init 进程。如果父进程比子进程先退出,子进程变成孤儿进程,将被 1 号进程托管。
僵尸进程:子进程终止,父进程还没有回收子进程残留在内核中的资源(PCB),称为该进程为僵尸进程。
僵尸进程有什么危害?内核为每个子进程保留了一个数据结构,包括进程编号、终止状态、使用 CPU 时间等。父进程如果处理了子进程退出的信息,内核就会释放这个数据结构,父进程如果没有处理子进程退出的信息,内核就不会释放这个数据结构,子进程的进程编号将一直被占用。系统可用的进程编号是有限的,如果产生了大量的僵尸进程,将因为没有可用的进程编号而导致系统不能产生新的进程。
产生僵尸进程的代码:
#include<stdio.h>
#include<unistd.h>
int main(int argc, char *argv[])
{
pid_t pid = fork();
if(pid == 0) // if Child process
{
puts("Hi, I am a child process");
}
else
{
printf("Child Process ID: %d\n", pid);
sleep(30);
}
if(pid == 0)
puts("End child process");
else
puts("End parent process");
return 0;
}等待 30 秒后父进程死亡,为什么僵尸进程也没有了。因为父进程死后,子进程相当于变成了孤儿,系统的 init 进程就会接管孤儿僵尸进程,调用 wait 函数把僵尸进程清理掉。
如何查看僵尸进程:僵尸进程在系统中通常标记为 Z (Zombie) 或者显示为 [defunct]。
使用 top 命令,可以看到系统的僵尸进程总数。
对于僵尸进程,kill 命令无效。
如何避免僵尸进程:
1)子进程退出的时候,内核会向父进程发头 SIGCHLD 信号,如果父进程用 signal (SIGCHLD,SIG_IGN) 通知内核,表示自己对子进程的退出不感兴趣,那么子进程退出后会立即释放数据结构。
2)父进程通过 wait ()/waitpid () 等函数等待子进程结束,在子进程退出之前,父进程将被阻塞。waitpid 可以不阻塞。
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, 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);
//返回值是子进程的编号3)如果父进程很忙,可以捕获 SIGCHLD 信号,在信号处理函数中调用 wait ()/waitpid ()。
wait 函数
头文件:<sys/wait.h>
函数功能:
- 阻塞等待子进程退出
- 回收子进程残留资源
- 获取子进程结束状态
pid_t wait(int *status)
成功返回子进程 PID,失败返回 - 1。
把得到的 int 类型的 status(这里的 status 是 int,和 wait 的参数 status 不是一回事。)传递给宏函数,可以得到子进程退出的信息:
宏函数:(看子进程如何死的)
WIFEXITED(status)为 1 说明子进程正常终止WEXITSTATUS(status)返回子进程的退出值WIFSIGNALED(status)为 1 说明子进程是被信号终止WTERMSIG(status)可以取得使进程终止的那个信号的编号。
wpid = wait(NULL) 不关心子进程结束原因
调用 wait 函数时,如果没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此需谨慎调用该函数。
下面介绍的这个函数不会引起程序的阻塞 ——waitpid 函数
waitpid 函数
pid_t waitpid(pid_t pid, int *status, int options);
- pid 指定要回收的子进程 pid。>0 回收指定 ID 的子进程,-1 回收任意子进程。0:同组的子进程
- option 和 status 可以为 NULL
- option 为 0 表示阻塞回收
返回值:返回成功回收的子进程 Pid,返回值为 0 表明参数 3 指定了 options 为 WNOHANG (记忆:WNOHANG ,w no hang 不悬挂,即不进入阻塞状态)(非阻塞 也就是说调用该函数时子进程没有结束,函数会直接返回 0,不会阻塞等待。),并且子进程没有结束。失败返回 - 1
一次 wait/waitpid 函数调用,只能回收一个子进程。
waitpid(-1,$status,0)==wait(&status)

回收多个子进程(使用循环)
多进程和信号
在多进程的服务程序中,如果子进程收到退出信号,子进程自行退出,如果父进程收到退出信号,则应该先向全部的子进程发送退出信号,然后自己再退出。
示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void FathEXIT(int sig); // 父进程的信号处理函数。
void ChldEXIT(int sig); // 子进程的信号处理函数。
int main()
{
// 忽略全部的信号,不希望被打扰。
for (int ii=1;ii<=64;ii++) signal(ii,SIG_IGN);
// 设置信号,在shell状态下可用 "kill 进程号" 或 "Ctrl+c" 正常终止些进程
// 但请不要用 "kill -9 +进程号" 强行终止。原因:kill -9 (SIGKILL) 是无法被捕获或忽略的,它会在操作系统内核层面直接杀死进程,会导致资源无法清理。
signal(SIGTERM,FathEXIT); signal(SIGINT,FathEXIT); // SIGTERM代表编号为15的信号,也就是kill进程号这个操作; SIGINT代表编号为2的信号,也就是Ctrl+c这个操作。
while (true)
{
if (fork()>0) // 父进程的流程
{
sleep(5); continue;
}
else // 子进程的流程
{
// 子进程会继承父进程的信号处理方式,因此子进程需要重新设置信号
signal(SIGTERM,ChldEXIT); // 子进程的退出函数与父进程不一样。
signal(SIGINT ,SIG_IGN); // 子进程不需要捕获SIGINT信号。当在终端按 Ctrl+c 时,信号会发给当前“前台进程组”的所有进程(父进程和所有子进程)。如果不忽略,用户按一次 Ctrl+c,父进程会想退出,所有子进程也会同时触发退出逻辑。
while (true)
{
cout << "子进程" << getpid() << "正在运行中。\n"; sleep(3); continue;
}
}
}
}
// 父进程的信号处理函数
void FathEXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中再次被信号中断。
signal(SIGINT,SIG_IGN); signal(SIGTERM,SIG_IGN);
cout << "父进程退出,sig=" << sig << endl;
kill(0,SIGTERM); // 第一个参数是0,代表向调用进程同组的所有进程发送信号。第二个参数代表发送哪种信号。
// 在这里增加释放资源的代码(全局的资源)。
exit(0);
}
// 子进程的信号处理函数。
void ChldEXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中再次被信号中断。
signal(SIGINT,SIG_IGN); signal(SIGTERM,SIG_IGN);
cout << "子进程" << getpid() << "退出,sig=" << sig << endl;
// 在这里增加释放资源的代码(只释放子进程的资源)。
exit(0);
}
进程组和会话
进程组,也称之为作业。BSD 于 1980 年前后向 Unix 中增加的一个新特性。代表一个或多个进程的集合。每个进程都属于一个进程组。操作系统设计的进程组的概念,是为了简化对多个进程的管理。
当父进程创建子进程的时候,默认子进程与父进程属于同一进程组。进程组 ID = 第一个进程 ID (组长进程)。所以,组长进程标识:其进程组 ID== 其进程 ID
可以使用 kill -SIGKILL -进程组ID(负的) 来将整个进程组内的进程全部杀死。
组长进程可以创建一个进程组,创建该进程组中的进程,然后终止。只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关。
进程组生存期:进程组创建到最后一个进程离开 (终止或转移到另一个进程组)。
一个进程可以为自己或子进程设置进程组 ID。
ps ajx |more 查看进程组 ID 会话 ID
参数 a 表示不仅列当前用户的进程,也列出所有其他用户的进程,参数 x 表示不仅列有控制终端的进程,也列出所有无控制终端的进程,参数 j 表示列出与作业控制相关的信息。

从左到右分别是:父进程 ID 进程 ID 进程组 ID 会话 ID 终端类型:?就是无终端
会话是由多个进程组组成的。
创建一个会话需要注意以下 6 点注意事项:
- 进程组组长不能创建会话
- 调用进程不能是进程组组长,该进程变成新会话首进程 (session header)
- 需有 root 权限 (ubuntu 不需要)
- 新会话丢弃原有的控制终端,该会话没有控制终端(不能和用户交互)
- 该调用进程是组长进程,则出错返回
- 建立新会话时,先调用 fork, 父进程终止,子进程调用 setsid
image-20260304121144999
pid_t getsid(pid_t pid); 得到指定进程 ID 所在的会话 ID,失败返回 - 1 pid 为 0 表示查看当前进程 session ID
pid_t setsid(void) 创建一个会话,调用该函数的进程成为会话组长和进程组长。失败返回 - 1
守护进程
Daemon (精灵) 进程,是 Linux 中的后台服务进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以 d 结尾的名字。Linux 后台的一些系统服务进程,没有控制终端,不能直接和用户交互。不受用户登录、注销的影响,一直在运行着,他们都是守护进程。如:预读入缓输出机制的实现;ftp 服务器;nfs 服务器等。
因为创建的会话没有控制终端,也就是说在后台运行,守护进程就是后台进程,所以我们可以利用会话来创建守护进程。
创建守护进程,最关键的一步是调用 setsid 函数创建一个新的 Session,并成为 Session Leader。
创建守护进程:
创建子进程,父进程退出
在子进程中创建新会话
- 改变当前目录位置:chdir 函数
int chdir(const char* path)
之所以要改变目录位置,是为了防止占用可卸载的文件系统(比如我的程序在 u 盘上,生成的可执行程序就会在当前目录。拔走 u 盘程序就崩溃了。)
- 改变当前目录位置:chdir 函数
重设文件权限掩码 umask 掩码
防止继承的文件创建屏蔽字拒绝某些权限,增加守护进程灵活性
mode_t umask(mode_t mask)mode_t 是八进制数 比如 0022umask 掩码:
你创建文件时指定了文件权限比如 0664,实际文件的权限是 0664&(~0022)=0644
如果创建文件时没有指定文件权限,那么实际文件的权限是 0777&(~0022)=0755(技巧:0777-0022=0755)
关闭或者重定向文件描述符
一般都是重定向文件描述符,把 012 这三个描述符重定向到
/dev/null。如果直接关闭这三个描述符,当守护进程需要 open 一个文件时就会返回文件描述符 0(返回可用的最小的文件描述符),和我们的编程逻辑不太符合。/dev/null是一个黑洞,往里面写数据,不管写多少都无所谓,会欺骗程序,返回一个写入的字节数,实际上数据直接被内核丢弃了。读该文件,read 函数会直接返回 0。守护进程的业务逻辑
示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
// 补充截图里省略的错误处理函数
void sys_err(const char *str) {
perror(str);
exit(1);
}
int main(void) {
pid_t pid;
int ret, fd;
// 1. 创建子进程,父进程退出。
// 让子进程成为孤儿进程,并在后台运行。
pid = fork();
if (pid > 0)
exit(0); // 父进程终止
// 2. 子进程创建新会话,脱离原终端的控制。
pid = setsid(); // 创建新会话
if (pid == -1)
sys_err("setsid error");
// 3. 改变当前工作目录,防止占用可卸载的文件系统(如 U盘 等)。
// 注意:测试时请把这里的路径改成你电脑上真实存在的目录,比如 "/" 或者 "/tmp"
ret = chdir("/home/itcast/28_Linux"); // 改变工作目录位置
if (ret == -1)
sys_err("chdir error");
// 4. 重设文件权限掩码,防止继承父进程的掩码导致权限受限。
umask(0022); // 改变文件访问权限掩码
// 5. 关闭标准输入,并将标准输出、标准错误重定向到 /dev/null(黑洞)
close(STDIN_FILENO); // 关闭文件描述符 0
fd = open("/dev/null", O_RDWR); // 此时由于 0 刚被关掉,open 默认分配最小可用 fd,所以 fd -> 0
if (fd == -1)
sys_err("open error");
// 【拼写修正】:原图为 STDOUT_FILNO 和 STDERR_FILNO,已修正为标准宏
dup2(fd, STDOUT_FILENO); // 把标准输出 (1) 也重定向到 /dev/null
dup2(fd, STDERR_FILENO); // 把标准错误 (2) 也重定向到 /dev/null
// 6. 核心业务逻辑(这里用死循环模拟后台一直运行的服务)
while (1) {
// 模拟守护进程业务
sleep(1);
}
return 0;
}image-20260304133417853
进程间通信(IPC)
进程间通信:两个进程可以交换数据。需要有一个都可以访问的内存空间。
进程间通信的本质:内核空间的一块缓冲区(buffer),大小一般是 4096 个字节。

IPC 的方式:管道(简单)、信号(开销小)、共享映射区(非血缘关系进程间)、本地套接字(稳定)
管道:内核借助环形队列机制,使用内核缓冲区实现的。有两个文件描述符引用,一个表示读一个表示写
特质:管道是伪文件,不占用磁盘空间;数据在管道中只能单向流动;数据从管道的写端流入管道,读端流出。(这里的读写是在程序的角度看的)
局限性:1. 数据不可以反复读 2. 采用半双工通信 3. 血缘关系进程间可用 4. 数据不能进程自己写自己读
pipe 函数创建并打开匿名管道。 int pipe(int pipfd[2])
fd [0] 读端 fd [1] 写端
成功返回 0,失败返回 - 1。


父子间通过管道进行通信的代码:

管道的读写行为:
读端:
- 管道有数据,read 直接返回实际读到的字节数
- 管道无数据,无写端,read 返回 0;有写端,read 阻塞等待,让出 CPU。
写端:
- 管道无读端,进程异常终止(SIGPIPE 导致的)。
- 有读端,若管道满了,write 阻塞;若管道没满,write 返回写入的字节数


程序输出结果:
父进程先执行完了,bash 抢占终端,所以会看到先出现命令提示符,再出现子进程结果。
加 sleep 也没有用,因为当子进程读管道的时候没有读到数据,就会阻塞到那里,直到父进程写入了数据。也就是说父进程一定比子进程先执行。
如何解决:只需让子进程写入、父进程读出,这样子进程永远会先结束。
练习:使用管道实现兄弟进程间通信,兄:ls 弟:wc-l 父:等待回收子进程

允许一个管道只有一个写端、多个读端。一个读端、多个写端,需要加 sleep 控制写入的顺序。
ulimit -a 可以查看管道缓冲区的大小
FIFO(命名管道)通过 FIFO,不相关的进程也能进行数据交换。利用内核空间创建缓冲区,通过缓冲区进行通信。
创建方式:
mkfifo 管道名int mkfifo(const char *pathname, mode_t mode);成功返回 0,失败返回 - 1。 需要添加头文件:<sys/stat.h>参数 pathname 是管道名 mode 是八进制权限(比如 0644)这个权限不是真正的权限,需要和 umask 掩码与一下。

FIFO 本质就是一个文件,两个进程一个进行写,一个进行读,从而实现通信。

先看左侧的 open 函数,第一个参数是文件路径;第二个参数就是打开方式。返回文件描述符。
再看左图的 read 函数,第一个参数是要读取的文件描述符,第二个参数 buf 是用来存放读出来数据的容器,第三个参数代表 buf 的容量,能从文件 fd 中读取的最大字节数。也就是 4096 个字节。返回值 len 是实际读到的字节数。
再看 write 函数,第一个参数是写到哪个文件,STDOUT_FILENO 代表标准输出设备也就是屏幕。第二个参数是把什么写到屏幕上去,第三个参数是读了多少字节,我就写多少字节。所以用的 len。
再看右侧的 write 函数,把 buf 里面的数据写到 fd 这 个文件中, strlen () 来计算这个字符串实际的长度(遇到 \0 停止计算)。注意 sprintf 会在字符串后面添加 \0。
文件实现进程间通信


父子进程共享文件描述符,那么 fd1 和 fd2 是同一个文件描述符吗?
fd1 和 fd2 不是同一个文件描述符。父子进程只共享在 fork() 之前 open 的文件描述符。上面的程序 open 函数是在 fork 之后进行的,操作系统内核会为这两个 open 操作分别创建独立的文件表项。


思考题程序:

存储映射 I/O
存储映射 I/O (Memory-mapped I/O) 使一个磁盘文件与内存空间中的一个缓冲区相映射。于是当从缓冲区中取数据,就相当于读文件中的相应字节。于此类似,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可在不适用 read 和 write 函数的情况下,使用地址(指针)完成 I/O 操作。
使用这种方法,首先应通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过 mmap 函数来实现。

mmap 函数原型:头文件 sys/mman.h
void* mmap(void* addr, size_t length,int prot, int flags, int fd, off_t offset)
参数说明:
addr 指定映射区的首地址,通常是 NULL,表示让系统自动分配
length 指定共享内存映射区的大小,要小于等于文件的实际大小。length 不能是 0
prot 共享内存区的读写属性 PROT_READ PROT_WRITE
flags 标注共享内存区的共享属性 MAP_SHARED MAP_PRIVATE (不能被同步到磁盘)
fd 用于创建共享内存区的那个文件的文件描述符
offset 偏移位置 默认 0 表示映射文件全部 需要是 4k 的整数倍
返回值:因为不知道共享内存里面要存放什么数据, 所以用泛型指针。成功返回映射区的首地址;失败返回一个宏 MAP_FAILED
munmap 函数 释放内存映射区
int munmap(void* addr, size_t length) 成功返回 0 失败返回 -1

lseek(fd, 10, SEEK_END) :将文件的读写指针向后移动 10 个字节 第二个参数:偏移量,想让读写指针相当于基准点(第三个参数)移动多少个字节
第三个参数:基准点 执行成功后,lseek 会返回当前指针距离文件开头的字节数。
write(fd, "\0", 1) :在空洞的末尾写入一个空字符。此时文件大小变成了 11 字节。
ftruncate(fd, 11) :直接将文件大小截断 / 扩展为 11 个字节。
mmap 注意事项:
创建映射区,mmap 需要 read 权限 当访问权限指定为共享时,mmap 的权限要小于等于创建映射区的那个文件的权限。
文件描述符 fd 在 mmap 创建映射区完成后即可关闭。
offset 必须是 4096 的整数倍,(因为 MMU 映射的最小单位就是 4k)
1. 创建映射区的过程中,隐含着一次对映射文件的读操作。
2. 当 MAP_SHARED 时,要求:映射区的权限应 <= 文件打开的权限 (出于对映射区的保护)。而 MAP_PRIVATE 则无所谓,因为 mmap 中的权限是对内存的限制。
3. 映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。
4. 特别注意,当映射文件大小为 0 时,不能创建映射区。所以:用于映射的文件必须要有实际大小!!mmap 使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。如,400 字节大小的文件,在建立映射区时 ofset 4096 字节,则会报出总线错。
5.munmap 传入的地址一定是 mmap 的返回地址。坚决杜绝指针 ++ 操作。
6. 如果文件偏移量必须为 4K 的整数倍
7.mmap 创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。
mmap 父子进程通信
先 mmap 再 fork。mmap 访问权限设置为共享


mmap 无血缘关系进程间通信
实质上 mmap 是内核借助文件帮我们创建了一个映射区,多个进程之间利用该映射区完成数据传递。由于内核空间多进程共享,因此无血缘关系的进程间也可以使用 mmap 来完成通信。只要设置相应的标志位参数 flags 即可。若想实现共享,当然应该使用 MAP_SHARED 了。
逻辑:两个进程打开同一个文件,创建映射区。flags 为 MAP_SHARED 一个进程写 一个进程读。


读端:

注意:mmap:数据可以反复读取
fifo:数据只能读一次,不能重复读 因为 fifo 是管道,管道是消息队列机制,数据读走就没了
而 mmap 是文件缓冲区的机制,数据可以反复读。
匿名映射区
没有血缘关系的进程不能用匿名映射区实现通信。

当你
open后立马unlink,unlink的作用是把这个文件名从操作系统的目录树里抹掉(解除硬链接), 这个文件在当前目录里就看不到了(别人无法再打开它),但因为你的进程还拿着fd(拿着钥匙),操作系统会在底层悄悄为你保留这个文件的实体。等你用完,调用close退出时,操作系统发现这文件既没名字,又没人用了,就会干脆利落地把它从磁盘上连根拔起,做到真正的 “阅后即焚”,不留痕迹。
通过标志位参数 flags 指定匿名映射区,用宏 MAP_ANONYMOUS 另外参数 fd 设置为 - 1。


共享内存:
多线程共享进程的地址空间,如果多个线程需要访问同一块内存,用全局变量就可以了。
在多进程中,每个进程的地址空间是独立的,不共享的,如果多个进程需要访问同一块内存,不能用全局变量,只能用共享内存。
共享内存(Shared Memory)允许多个进程(不要求进程之间有血缘关系)访问同一块内存空间,是多个进程之间共享和传递数据最高效的方式。进程可以将共享内存连接到它们自己的地址空间中,如果某个进程修改了共享内存中的数据,其它的进程读到的数据也会改变。
共享内存没有提供锁机制,也就是说,在某一个进程对共享内存进行读 / 写的时候,不会阻止其它进程对它的读 / 写。如果要对共享内存的读 / 写加锁,可以使用信号量。(线程用互斥锁和条件变量实现线程同步 进程用信号量实现进程同步)
- 创建或获取共享内存
int shmget(key_t key, size_t size, int shmflg);
key - 共享内存的键值,是一个整数(typedef unsigned int key_t),一般采用十六进制,例如 0x5005,不同共享内存的 key 不能相同。
size - 共享内存的大小,以字节为单位。
shmflg - 共享内存的访问权限,与文件的权限一样,例如 0666|IPC_CREAT, 0666 表示全部用户对它可读写, IPC_CREAT 表示如果共享内存不存在,就创建它。
返回值:成功返回共享内存的 id (一个非负的整数),失败返回 - 1(系统内存不足、没有权限)
用 ipcs -m 可以查看系统的共享内存,包括:键值(key),共享内存 id(shmid),拥有者(owner),权限(perms),大小(bytes)。
用 ipcrm -m 共享内存 id 可以手工删除共享内存,如下:
- 把共享内存链接到当前进程的地址空间
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmid - 由 shmget () 函数返回的共享内存标识。
shmaddr - 指定共享内存连接到当前进程中的地址位置,通常填 0,表示让系统来选择共享内存的地址。
shmflg - 标志位,通常填 0。
调用成功时返回共享内存起始地址,失败返回 (void*)-1 。
- 将共享内存从当前进程分离
int shmdt(const void *shmaddr);
shmaddr -shmat () 函数返回的地址。
调用成功时返回 0,失败时返回 - 1。
- 操作共享内存
int shmctl(int shmid, int command, struct shmid_ds *buf);
shmid -shmget () 函数返回的共享内存 id。
command - 操作共享内存的指令,如果要删除共享内存,填 IPC_RMID。
buf - 操作共享内存的数据结构的地址,如果要删除共享内存,填 0。
调用成功时返回 0,失败时返回 - 1。
示例:
#include <iostream>
#include <cstdio>
#include <cstdlib>//atoi函数的头文件
#include <cstring>//strcpy的头文件
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
using namespace std;
struct stgirl // 超女结构体。
{
int no; // 编号。
char name[51]; // 姓名,注意,不能用C++STL中的数据类型比如string。STL容器会动态的在堆区分配内存。
};
int main(int argc,char *argv[])
{
if (argc!=3) { cout << "Using:./demo no name\n"; return -1; }
// 第1步:创建/获取共享内存,键值key为0x5005,也可以用其它的值。
int shmid=shmget(0x5005, sizeof(stgirl), 0640|IPC_CREAT);
if ( shmid ==-1 )
{
cout << "shmget(0x5005) failed.\n"; return -1;
}
cout << "shmid=" << shmid << endl;
// 第2步:把共享内存连接到当前进程的地址空间。
stgirl *ptr=(stgirl *)shmat(shmid,0,0);
if (ptr==(void *)-1 )
{
cout << "shmat() failed\n"; return -1;
}
// 第3步:使用共享内存,对共享内存进行读/写。
cout << "原值:no=" << ptr->no << ",name=" << ptr->name << endl; // 显示共享内存中的原值。
ptr->no=atoi(argv[1]); // 对超女结构体的no成员赋值。 atoi把字符串转换成int
strcpy(ptr->name,argv[2]); // 对超女结构体的name成员赋值。
//ptr->name=argv[2];
cout << "新值:no=" << ptr->no << ",name=" << ptr->name << endl; // 显示共享内存中的当前值。
// 第4步:把共享内存从当前进程中分离。
shmdt(ptr);
// 第5步:删除共享内存。
//if (shmctl(shmid,IPC_RMID,0)==-1)
//{
// cout << "shmctl failed\n"; return -1;
//}
}循环队列
元素出队,头指针向后移动;元素入队,尾指针向后移动。尾部入队,头部出队。


多进程的生产消费者模型
//生产者程序
#include "_public.h"
int main()
{
struct stgirl // 循环队列的数据元素是超女结构体。
{
int no;
char name[51];
};
using ElemType=stgirl;
// 初始化共享内存。
int shmid=shmget(0x5005, sizeof(squeue<ElemType,5>), 0640|IPC_CREAT);
if ( shmid ==-1 )
{
cout << "shmget(0x5005) failed.\n"; return -1;
}
// 把共享内存连接到当前进程的地址空间。
squeue<ElemType,5> *QQ=(squeue<ElemType,5> *)shmat(shmid,0,0);
if ( QQ==(void *)-1 )
{
cout << "shmat() failed\n"; return -1;
}
QQ->init(); // 初始化循环队列。
ElemType ee; // 创建一个数据元素。
csemp mutex; mutex.init(0x5001); // 用于给共享内存加锁。
csemp cond; cond.init(0x5002,0,0); // 信号量的值用于表示队列中数据元素的个数。
mutex.wait(); // 加锁。
// 生产3个数据。
ee.no=3; strcpy(ee.name,"西施"); QQ->push(ee);
ee.no=7; strcpy(ee.name,"冰冰"); QQ->push(ee);
ee.no=8; strcpy(ee.name,"幂幂"); QQ->push(ee);
mutex.post(); // 解锁。
cond.post(3); // 实参是3,表示生产了3个数据。
shmdt(QQ); // 把共享内存从当前进程中分离。
}// 消费者程序
#include "_public.h"
int main()
{
struct stgirl // 循环队列的数据元素是超女结构体。
{
int no;
char name[51];
};
using ElemType=stgirl;
// 初始化共享内存。
int shmid=shmget(0x5005, sizeof(squeue<ElemType,5>), 0640|IPC_CREAT);
if ( shmid ==-1 )
{
cout << "shmget(0x5005) failed.\n"; return -1;
}
// 把共享内存连接到当前进程的地址空间。
squeue<ElemType,5> *QQ=(squeue<ElemType,5> *)shmat(shmid,0,0);
if ( QQ==(void *)-1 )
{
cout << "shmat() failed\n"; return -1;
}
QQ->init(); // 初始化循环队列。
ElemType ee; // 创建一个数据元素。
csemp mutex; mutex.init(0x5001); // 用于给共享内存加锁。
csemp cond; cond.init(0x5002,0,0); // 信号量的值用于表示队列中数据元素的个数。
while (true)
{
mutex.wait(); // 加锁。
while (QQ->empty()) // 如果队列空,进入循环,否则直接处理数据。必须用循环,不能用if
{
mutex.post(); // 解锁。
cond.wait(); // 等待生产者的唤醒信号。
mutex.wait(); // 加锁。
}
// 数据元素出队。
ee = QQ->front(); QQ->pop();
mutex.post(); // 解锁。
// 处理出队的数据(把数据消费掉)。
cout << "no=" << ee.no << ",name=" << ee.name << endl;
usleep(100); // 假设处理数据需要时间,方便演示。
}
shmdt(QQ);
}//_public.cpp
#include "_public.h"
// 如果信号量已存在,获取信号量;如果信号量不存在,则创建它并初始化为value。
// 如果用于互斥锁,value填1,sem_flg填SEM_UNDO。
// 如果用于生产消费者模型,value填0,sem_flg填0。
bool csemp::init(key_t key,unsigned short value,short sem_flg)
{
if (m_semid!=-1) return false; // 如果已经初始化了,不必再次初始化。
m_sem_flg=sem_flg;
// 信号量的初始化不能直接用semget(key,1,0666|IPC_CREAT)
// 因为信号量创建后,初始值是0,如果用于互斥锁,需要把它的初始值设置为1,
// 而获取信号量则不需要设置初始值,所以,创建信号量和获取信号量的流程不同。
// 信号量的初始化分三个步骤:
// 1)获取信号量,如果成功,函数返回。
// 2)如果失败,则创建信号量。
// 3) 设置信号量的初始值。
// 获取信号量。
if ( (m_semid=semget(key,1,0666)) == -1)
{
// 如果信号量不存在,创建它。
if (errno==ENOENT)
{
// 用IPC_EXCL标志确保只有一个进程创建并初始化信号量,其它进程只能获取。
if ( (m_semid=semget(key,1,0666|IPC_CREAT|IPC_EXCL)) == -1)
{
if (errno==EEXIST) // 如果错误代码是信号量已存在,则再次获取信号量。
{
if ( (m_semid=semget(key,1,0666)) == -1)
{
perror("init 1 semget()"); return false;
}
return true;
}
else // 如果是其它错误,返回失败。
{
perror("init 2 semget()"); return false;
}
}
// 信号量创建成功后,还需要把它初始化成value。
union semun sem_union;
sem_union.val = value; // 设置信号量的初始值。
if (semctl(m_semid,0,SETVAL,sem_union) < 0)
{
perror("init semctl()"); return false;
}
}
else
{ perror("init 3 semget()"); return false; }
}
return true;
}
// 信号量的P操作(把信号量的值减value),如果信号量的值是0,将阻塞等待,直到信号量的值大于0。
bool csemp::wait(short value)
{
if (m_semid==-1) return false;
struct sembuf sem_b;
sem_b.sem_num = 0; // 信号量编号,0代表第一个信号量。
sem_b.sem_op = value; // P操作的value必须小于0。
sem_b.sem_flg = m_sem_flg;
if (semop(m_semid,&sem_b,1) == -1) { perror("p semop()"); return false; }
return true;
}
// 信号量的V操作(把信号量的值减value)。
bool csemp::post(short value)
{
if (m_semid==-1) return false;
struct sembuf sem_b;
sem_b.sem_num = 0; // 信号量编号,0代表第一个信号量。
sem_b.sem_op = value; // V操作的value必须大于0。
sem_b.sem_flg = m_sem_flg;
if (semop(m_semid,&sem_b,1) == -1) { perror("V semop()"); return false; }
return true;
}
// 获取信号量的值,成功返回信号量的值,失败返回-1。
int csemp::getvalue()
{
return semctl(m_semid,0,GETVAL);
}
// 销毁信号量。
bool csemp::destroy()
{
if (m_semid==-1) return false;
if (semctl(m_semid,0,IPC_RMID) == -1) { perror("destroy semctl()"); return false; }
return true;
}
csemp::~csemp()
{
}#ifndef __PUBLIC_HH
#define __PUBLIC_HH 1
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/sem.h>
using namespace std;
// 循环队列。
template <class TT, int MaxLength>
class squeue
{
private:
bool m_inited; // 队列被初始化标志,true-已初始化;false-未初始化。
TT m_data[MaxLength]; // 用数组存储循环队列中的元素。
int m_head; // 队列的头指针。
int m_tail; // 队列的尾指针,指向队尾元素。
int m_length; // 队列的实际长度。
squeue(const squeue &) = delete; // 禁用拷贝构造函数。
squeue &operator=(const squeue &) = delete; // 禁用赋值函数。
public:
squeue() { init(); } // 构造函数。
// 循环队列的初始化操作。
// 注意:如果用于共享内存的队列,不会调用构造函数,必须调用此函数初始化。
void init()
{
if (m_inited!=true) // 循环队列的初始化只能执行一次。
{
m_head=0; // 头指针。
m_tail=MaxLength-1; // 为了方便写代码,初始化时,尾指针指向队列的最后一个位置。
m_length=0; // 队列的实际长度。
memset(m_data,0,sizeof(m_data)); // 数组元素清零。
m_inited=true;
}
}
// 元素入队,返回值:false-失败;true-成功。
bool push(const TT &ee)
{
if (full() == true)
{
cout << "循环队列已满,入队失败。\n"; return false;
}
// 先移动队尾指针,然后再拷贝数据。
m_tail=(m_tail+1)%MaxLength; // 队尾指针后移。
m_data[m_tail]=ee;
m_length++;
return true;
}
// 求循环队列的长度,返回值:>=0-队列中元素的个数。
int size()
{
return m_length;
}
// 判断循环队列是否为空,返回值:true-空,false-非空。
bool empty()
{
if (m_length == 0) return true;
return false;
}
// 判断循环队列是否已满,返回值:true-已满,false-未满。
bool full()
{
if (m_length == MaxLength) return true;
return false;
}
// 查看队头元素的值,元素不出队。
TT& front()
{
return m_data[m_head];
}
// 元素出队,返回值:false-失败;true-成功。
bool pop()
{
if (empty() == true) return false;
m_head=(m_head+1)%MaxLength; // 队列头指针后移。
m_length--;
return true;
}
// 显示循环队列中全部的元素。
// 这是一个临时的用于调试的函数,队列中元素的数据类型支持cout输出才可用。
void printqueue()
{
for (int ii = 0; ii < size(); ii++)
{
cout << "m_data[" << (m_head+ii)%MaxLength << "],value=" \
<< m_data[(m_head+ii)%MaxLength] << endl;
}
}
};
// 信号量。
class csemp
{
private:
union semun // 用于信号量操作的共同体。
{
int val;
struct semid_ds *buf;
unsigned short *arry;
};
int m_semid; // 信号量id(描述符)。
// 如果把sem_flg设置为SEM_UNDO,操作系统将跟踪进程对信号量的修改情况,
// 在全部修改过信号量的进程(正常或异常)终止后,操作系统将把信号量恢复为初始值。
// 如果信号量用于互斥锁,设置为SEM_UNDO。
// 如果信号量用于生产消费者模型,设置为0。
short m_sem_flg;
csemp(const csemp &) = delete; // 禁用拷贝构造函数。
csemp &operator=(const csemp &) = delete; // 禁用赋值函数。
public:
csemp():m_semid(-1){}
// 如果信号量已存在,获取信号量;如果信号量不存在,则创建它并初始化为value。
// 如果用于互斥锁,value填1,sem_flg填SEM_UNDO。
// 如果用于生产消费者模型,value填0,sem_flg填0。
bool init(key_t key,unsigned short value=1,short sem_flg=SEM_UNDO);
bool wait(short value=-1);// 信号量的P操作,如果信号量的值是0,将阻塞等待,直到信号量的值大于0。
bool post(short value=1); // 信号量的V操作。
int getvalue(); // 获取信号量的值,成功返回信号量的值,失败返回-1。
bool destroy(); // 销毁信号量。
~csemp();
};
#endif信号
基本概念
信号(signal)是软件中断,是进程之间相互传递消息的一种方法,用于通知进程发生了事件,但是,不能给进程传递任何数据。进程收到信号后,对该信号进行处理。(在特定事件发生时由操作系统向进程发送的消息)
给 B 发送信号,B 收到信号之前执行自己的代码,收到信号后,不管执行到程序的什么位置,都要暂停运行,去处理信号,处理完毕再继续执行。与硬件中断类似 —— 异步模式。但信号是软件层面上实现的中断。所有信号的产生和处理都是由【内核】完成的。
产生信号的几种方式:
1. 按键产生,如:Ctrl+c、Ctrl+z、Ctrl+\
2. 系统调用产生,如:kill、raise、abort
3. 软件条件产生,如:定时器 alarm
4. 硬件异常产生,如:非法访问内存 (段错误)、除 0 (浮点数例外)、内存对齐出错 (总线错误)
5. 命令产生,如:kill 命令
两个概念:递达:递送并且到达进程
未决:还没有到达进程 主要是由于阻塞导致该状态
Linux 内核的进程控制块 PCB 是一个结构体,类型是 task_struct,除了包含进程 id,状态,工作目录,用户 id,组 id,文件描述符表,还包含了信号相关的信息,主要指阻塞信号集和未决信号集。
阻塞信号集 (信号屏蔽字): 本质就是位图,用来记录信号的屏蔽状态。将某些信号加入集合,对他们设置屏蔽,当屏蔽 x 信号后,收到该信号时该信号的处理将推后。
未决信号集:1. 信号产生,未决信号集中描述该信号的位立刻翻转为 1,表信号处于未决状态。当信号被处理对应位翻转回为 0。这一时刻往往非常短暂。2. 信号产生后由于某些原因 (主要是阻塞) 不能抵达进程。这类信号的集合称之为未决信号集。在屏蔽解除前,信号一直处于未决状态。
内核产生信号,未决信号集对应的位变成 1;当信号正在被处理,未决信号集对应的位变成 0。阻塞信号集对应的位是 1,表示在当前信号处理的过程中,同一种信号会被阻塞,到不了进程。假如在当前信号处理的过程中,内核又发送了同一个信号,那么未决信号集对应的位又从 0 变为了 1。即使发送了很多同一个信号,未决信号集不记录次数,当前信号处理完,进程只会紧接着再处理一个信号,剩下的就丢弃了。这就是不排队机制。
信号是由内核产生的,然后发送给进程,到达进程后就被内核处理掉。从产生到到达进程这个阶段叫做未决。从 cpu 级别来看信号都会经历未决状态,信号被阻塞了就会一直处在未决状态。
发送信号
可以采用 kill 或 killall 命令向进程发送信号。
两者的区别: kill -信号的类型 进程编号 killall -信号的类型 进程名 。
在程序中也可以采用 kill() 函数向其他进程发送信号。
函数声明:
int kill(pid_t pid, int sig);
kill() 函数将参数 sig 指定的信号给参数 pid 指定的进程。
- 参数
pid有几种情况:
1)pid>0 将信号传给进程号为 pid 的进程。
2)pid=0 将信号传给和当前进程相同进程组的所有进程,常用于父进程给子进程发送信号,注意,发送信号者进程也会收到自己发出的信号。
进程组:每个进程都属于一个进程组,进程组是一个或多个进程集合,他们相互关联,共同完成一个实体任务,每个进程组都有一个进程组长,默认进程组 ID 与进程组长 ID 相同。父进程和子进程在同一个进程组。
3)pid=-1 将信号广播传送给系统内所有的进程,例如系统关机时,会向所有的登录窗口广播关机信息。
sig:准备发送的信号代码,假如其值为 0 则没有任何信号送出,但是系统会执行错误检查,通常会利用sig值为零来检验某个进程是否仍在运行。
返回值说明: 成功执行时,返回 0;失败返回 - 1,errno 被设置。
alarm 函数
设置定时器 (闹钟)。在指定的 seconds 后,内核会给当前进程发送 14 号 SIGALRM 信号。进程收到该信号,默认动作终止。采用自然计时法。
每个进程有且只有唯一一个定时器。
unsigned int alarm(unsigned int seconds); 返回 0 或剩余的秒数,无失败。
常用:alarm (0) 取消定时器,返回旧闹钟余下秒数。
例:alarm (5)→3sec→alarm (4)→5sec→alarm (5)→alarm (0)
解释下上面的流程:先定时器 5 秒 过了三秒之后又重新设置定时器 4s,第一次的 alarm 返回 2 秒,过了五秒,重新设置定时器 5s,第二次的 alarm 返回 0 秒。立马取消定时器,第三次的 alarm 返回 5s。
练习:编写程序,测试你使用的计算机 1 秒钟能数多少个数。
使用 time 命令可以查看程序执行的时间。程序运行的瓶颈在于 IO,优化程序,首选优化 IO。
实际执行时间 = 用户时间 + 等待时间(比如等设备 等内存) + 系统时间

setitimer 函数
设置定时器 (闹钟)。可代替 alarm 函数。精度微秒 us,可以实现周期定时。
int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
成功:0;失败:-1,
设置 errno 参数:
which:指定定时方式
①自然定时:ITIMER_REAL→14)SIGLARM 计算自然时间
②虚拟空间计时 (用户空间):ITIMER_VIRTUAL→26)SIGVTALRM 只计算进程占用 cpu 的时间
③运行时计时 (用户 + 内核):ITIMER_PROF→27)SIGPROF 计算占用 cpu 及执行系统调用的时间
new_value 定时秒数

old_value 传出参数 上次定时剩余时间
练习:使用 setitimer 函数实现 alarm 函数,重复计算机 1 秒数数程序。
拓展练习,结合 man page 编写程序,测试 it_interval、it_value 这两个参数的作用。
提示:it_interval:用来设定两次定时任务之间间隔的时间。it_value:定时的时长 两个参数都设置为 0,即清 0 操作。


信号类型
信号四要素:编号、名称、信号对应的事件、默认处理动作
| 信号名 | 信号值 | 默认处理动作 | 发出信号的原因 |
|---|---|---|---|
| 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 信号不能被忽略。
信号处理
进程对信号的处理方法有三种:
1)对该信号的处理采用系统的默认操作,大部分的信号的默认操作是终止进程。
2)设置信号的处理函数(捕捉函数),收到信号后,操作系统将调用该函数来处理信号。
3)忽略某个信号,对该信号不做任何处理,就像未发生过一样。
signal() 函数可以设置程序对信号的处理方式。(signal 又叫做信号注册函数、用来注册信号捕捉函数)
函数声明:
typedef void(*sighandler_t)(int) 定义了一个类型叫 sighandler_t 是一个函数指针,指向返回值为 void 参数为 int 的函数。
sighandler_t signal(int signum, sighandler_t handler);
参数
signum表示信号的编号(信号的值)。参数
handler表示信号的处理方式,有三种情况:
1)SIG_DFL:恢复参数 signum 信号的处理方法为默认行为。
2)一个自定义的处理信号的函数(捕获信号),函数的形参是信号的编号。
3)SIG_IGN:忽略参数 signum 所指的信号。
示例 1:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void timeout(int sig) //定义信号处理函数,这种类型的函数称为信号处理器(Handler)
{
if(sig == SIGALRM)
puts("Time out!");
alarm(2);
}
void keycontrol(int sig) //定义信号处理函数,这种类型的函数称为信号处理器(Handler)
{
if(sig == SIGINT)
puts("CTRL+C pressed");
}
int main(int argc, char * argv[])
{
int i;
signal(SIGALRM, timeout); //注册SIGALRM信号及其处理器
signal(SIGINT, keycontrol); //注册SIGINT信号,及其处理器
alarm(2); //预约2秒后发生SIGALRM信号。
for(i = 0; i < 3; i++)
{
puts("wait...");
sleep(100);
}
return 0;
}运行结果:

拓展:sleep 函数会被信号打断
当执行完 puts 语句后,进程进入睡眠状态,定时的 2 秒过去后操作系统会发送 SIGALRM 信号,将唤醒由于调用 sleep 函数而进入阻塞状态的进程。由于 SIGALRM 处理器中也调用了 alarm 函数,2s 后发生 SIGALRM 信号,又将唤醒第二次循环的进程。所以 for 语句中未到 sleep 函数中规定的时间就执行下一次循环。
示例 2:
服务程序运行在后台,如果想让中止它,杀掉不是个好办法,因为进程被杀的时候,是突然死亡,没有安排善后工作。
如果向服务程序发送一个信号,服务程序收到信号后,调用一个函数,在函数中编写善后的代码,程序就可以有计划的退出。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void EXIT(int sig)//注册回调函数exit,程序收到信号后,回调exit函数,回调exit函数时会把信号的编号传给exit函数。
{
cout << "收到了信号:" << sig << endl;
cout << "正在释放资源,程序将退出......\n";
// 以下是释放资源的代码。
cout << "程序退出。\n";
exit(0); // 进程退出。
}
int main(int argc,char *argv[])
{
// 忽略全部的信号,防止程序被信号异常中止。
for (int ii=1;ii<=64;ii++) signal(ii,SIG_IGN);
// 如果收到2和15的信号(Ctrl+c和kill、killall),本程序将主动退出。
signal(2,EXIT); signal(15,EXIT);
while (true)
{
cout << "执行了一次任务。\n";
sleep(1);
}
}
也就是说不需要把 signal 函数放在 while 循环里面。
如果向服务程序发送 0 的信号,可以检测程序是否存活。
实际上常用的信号注册函数是 sigaction 函数。
sigaction 函数(也可以用来注册信号捕捉函数)
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
成功:0;失败:-1,设置 errno
参数:
- act 新的处理方式
- oldact 旧的处理方式 传出参数 不关心就传 0
// struct sigaction结构体
struct sigaction {
void (*sa_handler)(int); //函数指针 指向信号处理函数
void (*sa_sigaction)(int, siginfo_t *, void *);//很少用
sigset_t sa_mask; //屏蔽信号集(阻塞信号集) 只在信号处理函数被调用期间生效
int sa_flags;// 通常设置为0 代表本信号使用默认属性 默认属性一般都是默认屏蔽。
void (*sa_restorer)(void);//弃用
};
信号捕捉特性:
- 捕捉函数执行期间,信号屏蔽字由 sa_mask 说了算,而不是 mask。函数执行完毕,恢复为 mask。(信号捕捉函数指的是 sig_catch 函数) 上面的程序中 sa_mask 都设置为了 0,flag 设置为 0,代表本信号被阻塞,因此其实 sa_mask 中的 SIGINT 被设置成了 1。
- XXX 信号捕捉函数执行期间,XXX 信号自动被屏蔽。(flag 需要设置为 0)
- 阻塞的常规信号不支持排队,产生多次只记录一次。(后 32 个实时信号支持排队)
利用信号处理技术来消灭僵尸进程:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<signal.h>
#include<sys/wait.h>
void read_childproc(int sig)
{
int status;
pid_t id = waitpid(-1, &status, WNOHANG);
if(WIFEXITED(status))
{
printf("Removed proc id: %d \n", id);
printf("Child send: %d \n", WEXITSTATUS(status));
}
}
int main(int argc, char * argv[])
{
pid_t pid;
struct sigaction act;
act.sa_handler = read_childproc; //为了注册信号处理器,声明sigaction结构体变量并在sa_handler成员存函数指针值。
sigemptyset(&act.sa_mask); // 调用sigemtpyset函数将sa_mask成员的所有位初始化为0.
act.sa_flags = 0; //sa_flags成员同样初始化为0。
sigaction(SIGCHLD, &act, 0); //注册SIGCHILD信号对应的处理器。若子进程终止,则调用第七行定义的函数,处理函数中调用了waitpid函数,所以子进程将正常终止,不会成为僵尸进程。
pid = fork();
if(pid == 0)
{
puts("Hi! I'm child process");
sleep(10);
return 12;
}
else
{
printf("Child proc id: %d \n", pid);
pid = fork();
if(pid == 0)
{
puts("Hi! I'm child process");
sleep(10);
exit(24);
}
else
{
int i;
printf("Child proc id: %d \n", pid);
for(i = 0; i < 5; i++) //为了等待发生SIGCHLD信号,是父进程共暂停5次,每次间隔5秒。发生信号时,父进程将被唤醒,因此实际暂停时间不到25秒。
{
puts("wait...");
sleep(5);
}
}
}
return 0;
}
这个程序存在问题,因为 SIGCHLD 信号是不排队的,如果第一个进程结束,父进程还没来得及处理信号,第二个子进程也结束了,那么父进程只会处理一个信号。
image-20260313201659846
内核实现信号捕捉过程:
当信号捕捉函数执行完成后,还需要返回给调用者。调用者就是 kernal 内核,因此还需要进入内核态,通过系统调用 sys_sigreturn 进入内核。

第二步:内核会去检查当前进程的 PCB 中的未决信号集(pending)和阻塞信号集(block)。如果发现有未被阻塞的信号正等着处理,就会进入下一步。
信号集操作函数

sigset_t set ; 自定义信号集 实际是个位图,每一位默认为 0
sigset_t 类型的本质是位图。但不应该直接使用位操作,而应该使用下面的函数操作 sigset_t,保证跨系统操作有效。
头文件 <signal.h>
sigemptyset(sigset_t *set) 清空信号集 全部置 0
sigefillset(sigset_t *set) 全部置 1
sigaddset(sigset_t *set, int signum) 将一个信号添加到集合中 signum 对应的那一位设置为 1
sigdelset(sigset_t *set, int signum) 将一个信号从集合中移除
sigismember(const sigset_t *set, int signum) 判断一个信号是否在集合,在返回 1,不在返回 0
设置信号屏蔽字(阻塞信号集)和解除屏蔽
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
成功:0;失败:-1,设置 errno
set:传入参数,是一个位图,set 中哪位置 1,就表示当前进程屏蔽哪个信号,即自定义信号集 set。
oldset:传出参数,保存旧的信号屏蔽集 mask。
how 参数取值:假设当前的信号屏蔽字为 mask
1.SIG_BLOCK: 当 how 设置为此值,set 表示需要屏蔽的信号。相当于 mask = mask|set
2.SIG_UNBLOCK: 当 how 设置为此,set 表示需要解除屏蔽的信号。相当于 mask = mask & ~set
3.SIG_SETMASK: 当 how 设置为此,set 表示用于替代原始屏蔽及的新屏蔽集。相当于 mask = set 若,调用 sigprocmask 解除了对当前若干个信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达。
读取当前进程的未决信号集的函数:
int sigpending(sigset_t *set); set 传出参数,代表未决信号集。返回值:成功:0;失败:-1,设置 errno
代码示例:


SIGCHLD 信号
(signal child)
以下情况,子进程会向父进程发送信号:
子进程终止时
子进程接收到 SIGSTOP 信号停止时
子进程处在停止态,接受到 SIGCONT 后唤醒时
父进程利用信号回收多个子进程程序:


现象:会出现僵尸态(僵尸态就是子进程已经死亡,但是父进程还没有回收)
原因:信号捕捉函数执行期间,可能会有多个子进程同时死亡,同时向父进程发送信号,但是会被阻塞。当捕捉函数执行完成后,由于不排队,虽然发送了多个信号,但是父进程只会处理其中一个信号,因此其他的子进程就变成了僵尸态。
解决方法:信号捕捉函数内部设置循环,用一次捕捉回收多个子进程。

bug:可能会出现父进程还没有注册好信号回调函数,子进程就已经死亡了。(如果子进程执行得极快,在父进程还没来得及调用 sigaction 注册好回调函数时就已经 exit 了,那么内核发给父进程的 SIGCHLD 信号就会按默认动作处理(即忽略),导致子进程变成僵尸进程。)
解决方法:把子进程死亡时发过来的信号设置为阻塞,捕捉函数注册完,再改成非阻塞。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void catch_child(int signo) //信号捕捉函数
{
pid_t wpid;
//此处有bug 使用 wait(NULL) 会导致如果还有一个子进程没死,父进程就会永远卡死在这个信号处理函数里出不去!
//while((wpid = wait(NULL)) != -1) {
while((wpid = waitpid(-1, NULL, WNOHANG)) > 0) { //WNOHANG (非阻塞) WNOHANG 的意思是“非阻塞”——如果有死掉的子进程就回收;如果没有死掉的,就立刻返回 0
printf("----------------catch child id %d\n", wpid);
}
return ;
}
int main(int argc, char *argv[])
{
pid_t pid;
int i;
// 1. 设置阻塞集,在 fork 之前阻塞 SIGCHLD
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGCHLD);
// 把 SIGCHLD 加入当前进程的信号屏蔽字中
sigprocmask(SIG_BLOCK, &set, NULL);
for (i = 0; i < 5; i++) //循环创建多个子进程
if ((pid = fork()) == 0)
break;
if (5 == i) { //父进程代码
struct sigaction act;
act.sa_handler = catch_child;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, NULL); //注册信号捕捉函数
//解除阻塞
sigprocmask(SIG_UNBLOCK, &set, NULL);
printf("I'm parent, pid = %d\n", getpid());
while (1); //回收完子进程,继续执行剩余工作
} else { //子进程代码
printf("I'm child pid = %d\n", getpid());
}
return 0;
}可能还有 bug:
比如在注册捕捉函数前,有多个子进程死亡,会给父进程发送多个信号,这些信号都被阻塞了,由于信号不支持排队,那么解除阻塞后到达父进程的只有一个信号。
实际上这个隐藏的 bug 已经被捕捉函数中的 waitpid 循环化解了。
总结一下:父进程利用信号回收多个子进程:

SIGCHLD 信号注意问题
- 子进程继承父进程的信号屏蔽字和信号处理动作,但子进程没有继承未决信号集 spending。
- 注意注册信号捕捉函数的位置。
- 应该在 fork 之前,阻塞 SIGCHLD 信号。注册完捕捉函数后解除阻塞。
拓展:慢速系统调用
系统调用可分为两类:慢速系统调用和其他系统调用。
慢速系统调用:可能会使进程永远阻塞的一类。如果在阻塞期间收到一个信号,该系统调用就被中断,不再继续执行 (早期);也可以设定系统调用是否重启。如,read、write、pause、wait...
其他系统调用:getpid、getppid、fork...
慢速系统调用被信号中断后比如 read 函数正在阻塞等待数据,突然来了一个 ctrl+c 信号,read 函数被终止,处理完该信号,read 函数应该被恢复,这才是合理的。
我们可以修改 sa_flags 参数来设置被信号中断后系统调用是否重启。SA_INTERRURT 不重启。SA_RESTART 重启。
线程
多线程强调的是一个进程中有多个线程。假如有 5 个进程,每个进程都有一个线程,这不算是多线程技术。
基本概念
LWP:light weight process 轻量级的进程
进程和线程的区别:
进程有独立地址空间 有独立的 PCB。线程有独立的 PCB,但没有独立的地址空间。
区别:在于是否共享地址空间。独居 (进程);合租 (线程)。

ps -Lf 进程ID 可以得到线程号(LWP),不是线程 ID。(线程 ID 是用来在进程中标识线程的;LWP 是 CPU 划分时间片用的)
进程是操作系统分配资源的最小单位,比如分配内存资源,都是按照进程数来进行分配的;线程是 CPU 调度和分配的最小单位。假如一个进程里面有三个线程,那么在 CPU 眼里是三个进程。
线程可看做寄存器和栈的集合。
从内核里看进程和线程是一样的,都有各自不同的 PCB,但是 PCB 中指向内存资源的三级页表是相同的。
三级映射:进程 PCB --> 页目录 (可看成数组,首地址位于 PCB 中) --> 页表 --> 物理页面 --> 内存单元

对于进程来说,相同的地址 (同一个虚拟地址) 在不同的进程中,反复使用而不冲突。原因是他们虽虚拟址一样但,页目录、页表、物理页面各不相同。相同的虚拟址,映射到不同的物理页面内存单元,最终访问不同的物理页面。
但!线程不同!两个线程具有各自独立的 PCB,但共享同一个页目录,也就共享同一个页表和物理页面。所以两个 PCB 共享一个地址空间。
实际上,无论是创建进程的 fork,还是创建线程的 pthread_create,底层实现都是调用同一个内核函数 clone。如果复制对方的地址空间,那么就产出一个 “进程”;如果共享对方的地址空间,就产生一个 “线程”。因此:Linux 内核是不区分进程和线程的。只在用户层面上进行区分。所以,线程所有操作函数 pthread_* 是库函数,而非系统调用。
线程共享资源:
- 文件描述符表
- 信号处理方式
- 当前工作目录
- 用户 ID 和组 ID
- 内存地址空间(./text ./data ./rodata ./bss heap 不共享栈)
线程非共享资源:
- 线程 ID
- 处理器现场和栈指针(内核栈)
- 独立的栈空间
- errno 变量(这是个全局变量)
- 信号屏蔽字
- 调度优先级
线程优缺点:提高并发性;开销小;共享、通信数据方便
线程之间为什么数据通信很方便呢?
因为线程之间共享虚拟地址空间,比如共享地址空间中的 data 数据段,数据段里面存放了全局变量和静态变量。不同的线程都可以访问到这些变量。
线程控制原语
pthread_self 函数,返回线程 ID。
pthread_t pthread_self(void);
pthread_t 类型在 Linux 中指的是 unsigned long int
pthread_create 函数,创建一个子线程
int pthread_create(pthread_t *thread, const pthread_atr_t *atr, void *(*start_routine) (void *), void *arg);
参数说明:
- thread 传出参数,用来保存子线程的线程 ID。
- atr 传入参数,表示线程属性,一般传 NULL
- start_routine 函数指针,指向线程执行函数。注意返回值和参数列表都是 void * 类型
- arg 函数的参数,没有参数就传 NULL
成功返回 0 失败返回 errno
注意编译链接的时候需要指定参数 - pthread,表示引入线程库。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void sys_err(const char *str)
{
perror(str);
exit(1);
}
void *tfn(void *arg) //线程执行逻辑
{
printf("thread: pid = %d, tid = %lu\n", getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
int ret = pthread_create(&tid, NULL, tfn, NULL);
if (ret != 0) {
perror("pthread_create error");
}
printf("main: pid = %d, tid = %lu\n", getpid(), pthread_self());
sleep(1); //避免子线程还没执行,主线程先死亡,导致进程地址空间被收回,子线程无法执行。
return 0;
}循环创建子线程:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
// 补充缺失的错误处理函数
void sys_err(const char *str)
{
perror(str);
exit(1);
}
void *tfn(void *arg)
{
// 将传进来的 void * 类型强转回 int
// 注意:在 64 位系统中,指针占 8 字节,int 占 4 字节,这里可能会报警告。
// 严谨一点可以写成 int i = (long)arg;
int i = (int)arg;
sleep(i);
printf("--I'm %dth thread: pid = %d, tid= %lu\n", i+1, getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[])
{
int i;
int ret;
pthread_t tid;
// 循环创建 5 个子线程
for (i = 0; i < 5; i++) {
// 核心技巧:直接把变量 i 的“值”强转成指针类型传进去,而不是传 &i
ret = pthread_create(&tid, NULL, tfn, (void *)i);
if (ret != 0) {
sys_err("pthread_create error");
}
}
// 主线程等 5 秒,防止主线程先退出导致子线程被迫终止
sleep(i);
printf("main: I'm Main, pid = %d, tid= %lu\n", getpid(), pthread_self());
return 0;
}

pthread_exit 函数 表示退出当前线程
void pthread_exit(void *retval); 参数:retval 表示线程退出状态,通常传 NULL
线程中,禁止使用 exit 函数。
void *tfn(void *arg)
{
int i = (int)arg;
sleep(i);
if (i == 2) //想要退出2号线程
{
// exit(0);exit是退出进程,会导致进程内所有线程全部退出。
//
//return NULL;//可以退出线程,return返回调用者
pthread_exit(NULL);//可以退出线程
}
printf("--I'm %dth thread: pid = %d, tid= %lu\n", i+1, getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[])
{
int i;
int ret;
pthread_t tid;
// 循环创建 5 个子线程
for (i = 0; i < 5; i++) {
// 核心技巧:直接把变量 i 的“值”强转成指针类型传进去,而不是传 &i
ret = pthread_create(&tid, NULL, tfn, (void *)i);
if (ret != 0) {
sys_err("pthread_create error");
}
}
// 主线程等 5 秒,防止主线程先退出导致子线程被迫终止
sleep(i);
printf("main: I'm Main, pid = %d, tid= %lu\n", getpid(), pthread_self());
return 0;
}#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void sys_err(const char *str)
{
perror(str);
exit(1);
}
void *tfn(void *arg) //线程执行逻辑
{
printf("thread: pid = %d, tid = %lu\n", getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
int ret = pthread_create(&tid, NULL, tfn, NULL);
if (ret != 0) {
perror("pthread_create error");
}
printf("main: pid = %d, tid = %lu\n", getpid(), pthread_self());
// sleep(1); //避免子线程还没执行,主线程先死亡,导致进程地址空间被收回,子线程无法执行。
pthread_exit(NULL);//main函数中用pthread_exit函数表示退出主线程,主线程退出,但让子线程继续执行。
}在不添加 sleep 控制输出顺序的情况下。pthread_create 在循环中,几乎瞬间创建 5 个线程,但只有第 1 个线程有机会输出(或者第 2 个也有,也可能没有,取决于内核调度)如果第 3 个线程执行了 exit,将整个进程退出了,所以全部线程退出了。所以,多线程环境中,应尽量少用,或者不使用 exit 函数,取而代之使用 pthread_exit 函数,将单个线程退出。任何线程里 exit 导致进程退出,其他线程未工作结束,主控线程退出时不能 return 或 exit。注意,pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_join 函数 阻塞等待线程退出,获取线程退出状态
int pthread_join(pthread_t thread, void **retval);
- 第一个参数是要等待退出的那个线程号
- retval:存储线程结束状态
对比记忆:
进程中:main 返回值、exit 参数 -->int;等待子进程结束 wait 函数参数 -->int *
线程中:线程函数返回值、pthread_exit-->void *;等待线程结束 pthread_join 函数参数 -->void **
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>
// 根据截图里的调用逻辑,逆向推导出的结构体定义
struct thrd {
int var;
char str[256];
};
// 错误处理函数
void sys_err(const char *str)
{
perror(str);
exit(1);
}
// 子线程函数
void *tfn(void *arg)
{
struct thrd *tval;
tval = malloc(sizeof(struct thrd));
if (tval == NULL) {
sys_err("malloc error");
}
// 往堆区内存里写入数据
tval->var = 100;
strcpy(tval->str, "hello thread");
// 将承载着数据的堆区指针强转并返回给主线程
return (void *)tval;
}
int main(int argc, char *argv[])
{
pthread_t tid;
struct thrd *retval; // 用来接住子线程返回值的指针
// 1. 创建子线程
int ret = pthread_create(&tid, NULL, tfn, NULL);
if (ret != 0) {
sys_err("pthread_create error");
}
// 2. 阻塞等待子线程结束,并回收它 return 出来的指针
// 注意这里必须传 &retval (也就是二级指针 void **)
ret = pthread_join(tid, (void **)&retval);//二级指针解引用得到retval,拿到了线程返回的tval
if (ret != 0) {
sys_err("pthread_join error");
}
// 3. 完美读取子线程传出的数据
printf("child thread exit with var= %d, str= %s\n", retval->var, retval->str);
// 4. 【关键收尾】:用完数据后,千万别忘了把子线程 malloc 出来的这块内存释放掉!
// 否则在长时间运行的服务器程序中,会导致内存泄漏。
free(retval);
// 5. 主线程安全退出
pthread_exit(NULL);
}再来看下面这个 demo 程序:retval 得到的内容就是 74,所以不需要解引用。

再来看下面这个 demo,tval 是局部变量,返回了一个局部变量的地址。这是错误的,函数调用完,局部变量内存释放,tval 变成了野指针。

在 main 函数中定义线程返回的状态:

练习:使用 pthread_join 将循环创建的多个子线程回收。
提示:采用数组。
pthread_cancel 函数:杀死一个线程。成功返回 0 失败返回 errno
取消一个线程,必须要设置取消点(保存点)。(进入内核的契机)可以使用 pthread_testcancel() 手动设置取消点。
取消点 :是线程检查是否被取消,并按请求进行动作的一个位置 。通常是一些系统调用 creat,open,pause,close,read,write..... 执行命令 man 7 pthreads 可以查看具备这些取消点的系统调用列表。也可参阅 APUE.12.7 取消选项小节。
可粗略认为一个系统调用 (进入内核) 即为一个取消点。如线程中没有取消点,可以通过调用 pthread_testcancel 函数自行设置一个取消点。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
void *tfn1(void *arg)
{
printf("thread 1 returning\n");
return (void *)111;
}
void *tfn2(void *arg)
{
printf("thread 2 exiting\n");
pthread_exit((void *)222);
}
void *tfn3(void *arg)
{
while (1) {
printf("thread 3: I'm going to die in 3 seconds ...\n");
sleep(1);
/*pthread_testcancel(); //自己添加取消点*/
}
return (void *)666;
}
int main(void)
{
pthread_t tid;
void *tret = NULL;
pthread_create(&tid, NULL, tfn1, NULL);
pthread_join(tid, &tret);
printf("thread 1 exit code = %d\n\n", (int)tret);//111
pthread_create(&tid, NULL, tfn2, NULL);
pthread_join(tid, &tret);
printf("thread 2 exit code = %d\n\n", (int)tret);//222
pthread_create(&tid, NULL, tfn3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &tret);
printf("thread 3 exit code = %d\n", (int)tret); //-1
return 0;
}成功被 cancel 函数杀死的线程,返回 - 1。可以用 pthread_join 回收。
总结:终止某个线程而不终止整个进程,有三种方法:
- 从线程主函数 return。这种方法对主控线程不适用,从 main 函数 return 相当于调用 exit。
- 一个线程可以调用 pthread_cancel 终止同一进程中的另一个线程。
- 线程可以调用 pthread_exit 终止自己。
pthread_detach 函数:分离线程。成功返回 0,失败返回 errno。线程终止时会自动回收 PCB 残留资源。不需要在主线程中使用 join 函数。
int pthread_datach(pthread_t thread)
一般情况下,线程终止后,其终止状态一直保留到其它线程调用 pthread_join 获取它的状态为止。但是线程也可以被置为 detach 状态,这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。
不能对一个已经处于 detach 状态的线程调用 pthread_join,这样的调用将返回 EINVAL 错误。也就是说,如果已经对一个线程调用了 pthread_detach 就不能再调用 pthread_join 了。
虽然分离了线程,但是当主线程结束的时候,整个进程结束,分离的子线程仍然会结束。想实现主线程结束但子线程还能继续运行,只需要在主线程中调用 pthread_exit 函数。
检查错误:之前都是用 perror(str) 打印错误信息。perror 会在底层访问 errno。翻译成字符串传出来,再拼接上提示信息 str。
在线程中检查错误:需要用 strerror 函数。因为线程的函数在调用失败时直接返回错误号,它不会去设置全局的 errno,所以用 perror 打印错误信息实际上打印的是没有更新的 errno。
char* strerror(int errnum)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h> // strerror 需要用到这个头文件
void *tfn(void *arg)
{
printf("thread: pid = %d, tid = %lu\n", getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
// 1. 创建子线程
int ret = pthread_create(&tid, NULL, tfn, NULL);
if (ret != 0) {
// 注意:pthread 系列函数通常不设置 errno,而是直接返回错误号
// 所以这里用 strerror(ret) 来打印更准确
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(1);
}
// 2. 设置线程分离
// 一旦分离,子线程结束时,系统会自动回收它的资源(PCB、栈等)
ret = pthread_detach(tid);
if (ret != 0) {
perror("pthread_detach error");
}
// 主线程稍微等一下,让子线程有时间打印输出
sleep(1);
// 3. 【核心测试点】:尝试去 join 一个已经被 detach 的线程
ret = pthread_join(tid, NULL);
printf("join ret = %d\n", ret);
// 这里必然会报错!
if (ret != 0) {
// 如果是在 Linux 下,通常会打印出 "Invalid argument" (错误号 22, EINVAL)
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
}
return 0;
}线程属性设置为分离
涉及到三个函数:
初始化线程属性: int pthread_atr_init(pthread_atr_t *atr); 成功:0;失败:错误号。atr 是传出参数
设置线程属性: int pthread_atr_setdetachstate(pthread_atr_t *atr, int detachstate);
参数:
atr:已初始化的线程属性
detachstate:PTHREAD_CREATE_DETACHED(分离线程)
PTHREAD _CREATE_JOINABLE(非分离线程)
这里要注意的一点是,如果设置一个线程为分离线程,而这个线程运行又非常快,它很可能在 pthread_create 函数返回之前就终止了,它终止以后就可能将线程号和系统资源移交给其他的线程使用,这样调用 pthread_create 的线程就得到了错误的线程号。要避免这种情况可以采取一定的同步措施,最简单的方法之一是可以在被创建的线程里调用 pthread_cond_timedwait 函数,让这个线程等待一会儿,留出足够的时间让函数 pthread_create 返回。设置一段等待时间,是在多线程编程里常用的方法。但是注意不要使用诸如 wait () 之类的函数,它们是使整个进程睡眠,并不能解决线程同步的问题。
销毁线程属性: int pthread_atr_destroy(pthread_atr_t *atr); 成功:0;失败:错误号。
步骤:
创建线程属性变量 pthread_atr_t atr;
初始化线程属性 pthread_atr_init(&atr);
设置线程属性 pthread_atr_setdetachstate(&atr, PTHREAD_CREATE_DETACHED)
创建线程 pthread_create(*,&atr,*,*)
销毁线程属性 pthread_atr_destroy(&atr)
线程使用注意事项
主线程退出其他线程不退出,主线程应调用 pthread_exit
避免僵尸线程:pthread_join pthread_detach pthread_create 指定分离属
被 join 线程可能在 join 函数返回前就释放完自己的所有内存资源,所以不应当返回被回收线程栈中的值;
malloc 和 mmap 申请的内存可以被其他线程释放(因为堆内存是线程共享的)
应避免在多线程模型中调用 fork 除非,马上 exec,子进程中只有调用 fork 的线程存在,其他线程在子进程中均 pthread_exit
信号的复杂语义很难和多线程共存,应避免在多线程引入信号机制。
线程同步
不同的对象,对 “同步” 的理解方式略有不同。如,设备同步,是指在两个设备之间规定一个共同的时间参考;数据库同步,是指让两个或多个数据库内容保持一致,或者按需要部分保持一致;文件同步,是指让两个或多个文件夹里的文件保持一致。等等。
编程中、通信中所说的同步与生活中大家印象中的同步概念略有差异。“同” 字应是指协同步调,按预定的先后次序运行,防止数据混乱,产生与时间有关的错误。
为什么需要进行线程同步?
下面是一个很经典的例子:多个线程访问一个全局变量。
假设 2 个线程要执行将变量值逐次加 1 的工作,

上图描述的是 2 个线程准备将变量 num 的值加 1 的情况。在此状态下,线程 1 将变量 num 的值增加到 100 后,线程 2 再访问 num 时,变量 num 中间按照我们预先保存 101。下图是线程 1 将变量 num 完成增加后的情形。

上图中需要注意值的增加方式,值的增加需要 CPU 运算完成,变量 num 中的值不会自动增加。线程 1 首先读该变量的值并将其传递到 CPU,获得加 1 之后的结果 100,最后再把结果写回变量 num,这样 num 中就保存 100。接下来给出线程 2 的执行过程:

变量 num 中将保存 101,但这是理想的情况。线程 1 完全增加 num 值之前,线程 2 完全有可能通过切换得到 CPU 资源。
下图描绘的是线程 1 读取变量 num 的值并完成加 1 运算时的情况,只是加 1 后的结果尚未写入变量 num。

接下来就要将 100 保存到变量 num 中,但执行该操作前,执行流程跳转到了线程 2。幸运的是,线程 2 完成了加 1 运算,并将加 1 之后的结果写入变量 num。

从上图可以看到,变量 num 的值尚未被线程 1 加到 100,因此线程 2 读到的变量 num 的值为 99,结果是线程 2 将 num 值改成 100。还剩下线程 1 加运算后的值写入变量 num 的操作。

很可惜,此时线程 1 将自己的运算结果 100 再次写入变量 num,结果变量 num 变成 100。虽然线程 1 和线程 2 各做了 1 次加 1 运算,却得到了意想不到的结果。因此,线程访问变量 num 时应该阻止其他线程访问,直到线程 1 完成运算。这就是同步。
再来介绍下线程安全和非线程安全函数。线程安全函数被多个线程同时调用时不会引发问题。非线程安全函数被同时调用时会引发问题。
大多数标准函数都是线程安全的函数。更幸运的是,我们不用自己区分线程安全的函数和非线程安全的函数。因为这些平台在定义非线程安全函数的同时,提供了具有相同功能的线程安全的函数。线程安全函数的名称后缀通常为_r。
编译时通过添加 -D_REENTRANT 选项定义宏。可以将非线程安全函数改为线程安全函数。
再介绍临界区:函数内同时运行多个线程时引起问题的多条语句构成的代码块。说白了就是多个线程同时访问某个区域可能会发生问题。这个区域就称为临界区。
何时需要同步:
- 多个线程同时访问同一个内存空间
- 需要指定访问同一内存空间的线程执行顺序
互斥锁(互斥量)
我们可以通过锁机制来实现线程同步。线程要访问公共数据的时候【应该】先拿到锁(mutex),拿到锁才可以访问数据,访问完再解锁。但是锁不是强制的。可以不拿锁直接访问。
本质是个结构体。为简化理解,应用时可忽略其实现细节,简单当成整数看待。只有两种取值:0 1
使用互斥锁的一般步骤:
- 创建锁
- 初始化锁
- 上锁
- 访问数据
- 解锁
- 销毁锁
pthread_mutex_init 函数
int pthread_mutex_init(pthread_mutex_t* restrict mutex,const pthread_mutexattr_t *restrict attr)
restrict 关键词意思是只能用 mutex 这个指针去修改内存数据,而不能用其他指针。
- mutex 传出参数 指向互斥量的一个指针
- attr 互斥量参数 通常传 NULL
- 静态初始化:如果互斥锁 mutex 是静态分配的(定义在全局,或加了 static 关键字修饰),可以直接使用宏进行初始化。e.g.pthead_mutex_t muetx = PTHREAD_MUTEX_INITIALIZER;
- 动态初始化:局部变量应采用动态初始化。e.g.pthread_mutex_init (&mutex, NULL)
- 初始化成功,可以认为 mutex 变为 1
pthread_mutex_destroy 函数
int pthread_mutex_destroy(pthread_mutex_t *mutex)
pthread_mutex_lock 函数
int pthread_mutex_lock(pthread_mutex_t *mutex);
上锁可以认为将 mutex--。
pthread_mutex_trylock 函数
int pthread_mutex_trylock(pthread_mutex_t *mutex);
lock 和 trylock 的区别:lock 上锁失败线程会阻塞等待锁释放;trylock 尝试上锁失败,会一直返回一个错误号,不会阻塞。
pthread_mutex_unlock 函数
int pthread_mutex_unlock(pthread_mutex_t *mutex);
解锁可以认为将 mutex++。
lock 尝试加锁,如果加锁不成功,线程会阻塞,阻塞到持有该互斥量的线程解锁为止。unlock 主动解锁函数 ==,同时将阻塞在该锁上的所有线程全部唤醒 ==,至于哪个线程先被唤醒,取决于优先级、调度。默认:先阻塞、先唤醒。
示例:
//让主线程打印大写的 HELLO WORLD,子线程打印小写的 hello world。如果不加锁,由于 sleep 会主动让出 CPU 导致频繁的线程切换,终端里就会打印出诸如 HELLO hello WORLD world 这种交错混乱的错位字符。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
// 互斥锁必须定义为全局变量,这样主线程和子线程才能看到同一把锁
pthread_mutex_t mutex;
void *tfn(void *arg)
{
srand(time(NULL));
while (1) {
pthread_mutex_lock(&mutex); // 上锁
printf("hello ");
// 模拟长时间操作共享资源(终端屏幕),导致 CPU 易主
sleep(rand() % 3);
printf("world\n");
pthread_mutex_unlock(&mutex); // 解锁
sleep(rand() % 3);
}
return NULL;
}
int main(void)
{
pthread_t tid;
srand(time(NULL));
// 初始化互斥锁
int ret = pthread_mutex_init(&mutex, NULL);
if (ret != 0) {
fprintf(stderr, "mutex init error:%s\n", strerror(ret));
exit(1);
}
pthread_create(&tid, NULL, tfn, NULL);
while (1) {
pthread_mutex_lock(&mutex); // 上锁
printf("HELLO ");
sleep(rand() % 3);
printf("WORLD\n");
pthread_mutex_unlock(&mutex); // 解锁
sleep(rand() % 3);
}
pthread_join(tid, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}变化版本:把两个线程中 sleep 和解锁的语句调换一下顺序。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
// 互斥锁必须定义为全局变量,这样主线程和子线程才能看到同一把锁
pthread_mutex_t mutex;
void *tfn(void *arg)
{
srand(time(NULL));
while (1) {
pthread_mutex_lock(&mutex); // 上锁
printf("hello ");
// 模拟长时间操作共享资源(终端屏幕),导致 CPU 易主
sleep(rand() % 3);
printf("world\n");
sleep(rand() % 3);
pthread_mutex_unlock(&mutex); // 解锁
}
return NULL;
}
int main(void)
{
pthread_t tid;
srand(time(NULL));
// 初始化互斥锁
int ret = pthread_mutex_init(&mutex, NULL);
if (ret != 0) {
fprintf(stderr, "mutex init error:%s\n", strerror(ret));
exit(1);
}
pthread_create(&tid, NULL, tfn, NULL);
while (1) {
pthread_mutex_lock(&mutex); // 上锁
printf("HELLO ");
sleep(rand() % 3);
printf("WORLD\n");
sleep(rand() % 3);
pthread_mutex_unlock(&mutex); // 解锁
}
pthread_join(tid, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
如果将 sleep 放在解锁的前面,那么程序的运行结果会发生一些变化:会一直打大写的 HELLOWORLD,过段时间一直打小写的 helloworld。大写和小写不会交替进行。
这是因为在子线程中,解锁之后没有 sleep,很可能会立刻进入下一个 while 循环,进行上锁。另一个线程很难获得上锁的机会。
另外该程序存在一个性能缺陷:在访问完共享资源时,没有立即释放锁,而是抱着锁 sleep 了几秒。
多线程的初衷是为了让 CPU 同时处理不同的任务。一旦你在持有锁的状态下休眠(或者进行耗时的网络 I/O、磁盘读写),其他需要这把锁的线程全部会被强制挂起。你的多线程程序在这一刻,运行效率甚至比单线程还要低。
在访问共享资源前加锁,访问结束后立即解锁。锁的 “粒度” 应越小越好。
死锁
情况一:对一个互斥量反复上锁。
线程 A 对互斥量上锁后,mutex 变为 0。该线程再次对互斥量上锁,会发生阻塞,等待互斥量解锁,然而线程 A 就是该锁的拥有者,需要他来解锁。所以造成死锁。
情况二:线程 A 拥有锁 A,需要锁 B,线程 B 拥有锁 B,需要锁 A。

作业:编写程序,实现两种死锁。
自旋锁
读写锁
锁只有一把。以读方式上锁 -- 读锁 以写方式上锁 -- 写锁
读共享,写独占。写锁优先级高。
比如多个线程既有读锁也有写锁,写锁优先级高,会先上锁。读锁阻塞等待。如果多个线程只有读锁,那么他们都可以进行上锁,因为读共享。如果多个线程只有写锁,那么会一个个阻塞等待。因为写独占。
读写锁也叫共享 - 独占锁。当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。
再来系统的总结一下:
- 读写锁是 “写模式加锁” 时,解锁前,所有对该锁加锁的线程都会被阻塞。当解锁后,会优先满足写模式的锁
- 读写锁是 “读模式加锁” 时,如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞。
- 读写锁是 “读模式加锁” 时,既有试图以写模式加锁的线程,也有试图以读模式加锁的线程。那么读写锁会阻塞随后的读模式锁请求。优先满足写模式锁。
这里说的总结,场景是线程 A 采用读模式或写模式加锁成功后,线程 BC 加锁会出现什么情况。

读写锁的函数:
读写锁变量: pthread_rwlock_t rwlock
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockatr_t *restrict atr);
参 2:atr 表读写锁属性,通常使用默认属性,传 NULL 即可。
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
以读模式上锁:
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
以写模式上锁:
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
解锁:
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
尝试加锁:
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
示例:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
int counter;
pthread_rwlock_t rwlock;
/* 3个线程不定时写同一全局资源,
5个线程不定时读同一全局资源
*/
void *th_write(void *arg)
{
int t, i = (int)arg;
while (1) {
pthread_rwlock_wrlock(&rwlock);
t = counter;
usleep(1000);
printf("=======write %d: %lu: counter=%d ++counter=%d\n", i, pthread_self(), t, ++counter);
pthread_rwlock_unlock(&rwlock);
usleep(10000);
}
return NULL;
}
void *th_read(void *arg)
{
int i = (int)arg;
while (1) {
pthread_rwlock_rdlock(&rwlock);
printf("----------------------------read %d: %lu: %d\n", i, pthread_self(), counter);
pthread_rwlock_unlock(&rwlock);usleep(2000);
}
return NULL;
}
int main(void)
{
int i;
pthread_t tid[8];
pthread_rwlock_init(&rwlock, NULL);
for (i = 0; i < 3; i++)
pthread_create(&tid[i], NULL, th_write, (void *)i);
for (i = 0; i < 5; i++)
pthread_create(&tid[i+3], NULL, th_read, (void *)i);
for (i = 0; i < 8; i++)
pthread_join(tid[i], NULL);
pthread_rwlock_destroy(&rwlock);
return 0;
}乐观锁和悲观锁
条件变量
本身不是锁,通常结合互斥锁进行使用。
条件变量是一种线程同步机制。当条件不满足时,相关线程被一直阻塞,直到某种条件出现,这些线程才会被唤醒。
创建条件变量: pthead_cond_t cond;
初始化: int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condatr_t *restrict atr);
静态初始化: pthead_cond_t cond = PTHREAD_COND_INITIALIZER;
动态初始化: pthead_cond_init(&cond,NULL);
pthread_cond_wait 函数:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
函数作用:1. 阻塞等待条件变量 cond 满足
2. 释放互斥锁(解锁互斥量)相当于 pthread_mutex_unlock (&mutex);1.2 两步为一个原子操作。
3. 当条件满足时,pthread_cond_wait 函数返回,解除阻塞并重新加锁 pthread_mutex_lock (&mutex);
原子操作:不可再分,不会失去 CPU。

pthread_cond_signal() :唤醒至少一个阻塞在条件变量上的线程
pthread_cond_broadcast() 唤醒所有阻塞在条件变量上的线程
这两个函数用来通知条件已经满足。
pthread_cond_timedwait 函数:限时等待一个条件变量
销毁条件变量: int pthread_cond_destroy(pthread_cond_t *cond);
采用条件变量实现生产者和消费者模型:

消费者在
wait前加锁,根本目的不是为了锁住wait这个动作本身。如果在 wait 前没有加锁,有可能会出现,消费者准备调用 wait 函数时 CPU 切换给生产者,生产者生产数据,把数据放到公共区域,通知消费者条件已经满足。而此时消费者还没有进入 wait 函数,所以他收不到这条信息。CPU 切换到消费者,消费者进入 wait 函数就一直阻塞等待条件满足了。
示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
// 定义链表节点结构体(也就是我们比喻中的“快递”)
struct msg {
struct msg *next;
int num;
};
// 定义公共区域:链表的头指针
struct msg *head;
// 静态初始化条件变量和互斥锁
pthread_cond_t has_product = PTHREAD_COND_INITIALIZER;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// 消费者线程函数
void *consumer(void *p)
{
struct msg *mp;
for (;;) {
pthread_mutex_lock(&lock); // 加锁,准备去柜子拿快递
// 🚨 核心考点:头指针为空,说明没有节点。这里可以为 if 吗?绝对不行!
if (head == NULL) {
// 睡着并把锁扔出去。醒来时会自动重新拿回锁。
pthread_cond_wait(&has_product, &lock);
}
// 醒来且拿到锁了,模拟消费掉一个产品(从链表头部摘下一个节点)
mp = head;
head = mp->next;
pthread_mutex_unlock(&lock); // 东西拿到了,赶紧解锁让别人用
printf("-Consume ---%d\n", mp->num);
free(mp); // 吃完释放内存
sleep(rand() % 5);
}
}
// 生产者线程函数
void *producer(void *p)
{
struct msg *mp;
while (1) {
mp = malloc(sizeof(struct msg));
mp->num = rand() % 1000 + 1; // 模拟生产一个产品
printf("-Produce ---%d\n", mp->num);
pthread_mutex_lock(&lock); // 加锁,准备把产品放进柜子
// 头插法:把新节点挂在链表最前面
mp->next = head;
head = mp;
pthread_mutex_unlock(&lock); // 放完了解锁
// 放完产品后,按响大喇叭,唤醒等待在条件变量上的消费者
pthread_cond_signal(&has_product);
sleep(rand() % 5);
}
}
int main(int argc, char *argv[])
{
pthread_t pid, cid;
srand(time(NULL));
// 创建生产者和消费者线程
pthread_create(&pid, NULL, producer, NULL);
pthread_create(&cid, NULL, consumer, NULL);
// 阻塞回收线程
pthread_join(pid, NULL);
pthread_join(cid, NULL);
return 0;
}一个生产者、多个消费者:
需要把下面代码片段中的 if 改为 while


上述这个现象被称为:虚假唤醒
较于 mutex 而言,条件变量可以减少竞争。如直接使用 mutex,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果链表中没有数据,消费者之间竞争互斥锁是无意义的。有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的 竞争。提高了程序效率。
信号量(semaphore)
信号量相当于初始化值为 N 的互斥量。信号量和信号没有关系。N 表示可以同时访问共享数据区的线程数。
信号量本质上是一个非负数的计数器,用于给共享资源建立一个标志,表示该共享资源被占用的情况。
信号量的两种操作:

信号量的应用场景:

ipcs -s :查看信号量
ipcrm sem semid :删除信号量
sem_init 函数 int sem_init(sem_t *sem, int pshared, unsigned int value);
- 参数 1:信号量
- 参数 2:传 0,用于线程间;取非 0 用于进程间
- 参数 3:指定信号量初值 N
sem_destroy 函数 int sem_destroy(sem_t *sem);
sem_wait 函数 相当于 lock 操作,将信号量 --。信号量为 0 时,线程阻塞。
int sem_wait(sem_t *sem);
sem_trywait 函数
int sem_trywait(sem_t *sem);
sem_timedwait 函数 int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
- 参 2:abs_timeout 采用的是绝对时间。
//定时1s
time_t cur = time(NULL);获取当前时间。
struct timespec t;定义timespec结构体变量t
t.tv_sec = cur+1;定时1秒
t.tv_nsec = t.tv_sec +100;
sem_timedwait(&sem, &t);传参sem_post 函数 相当于 unlock 操作,将信号量 ++,同时唤醒阻塞在信号量上的线程。 int sem_post(sem_t *sem);
wait 是用来阻塞的,post 是用来解除阻塞的。
场景:线程 A 从用户输入得到值后存入全局变量 num,此时线程 B 将取走该值并累加。该过程共进行 5 次,完成后输出总和并退出程序。
#include<stdio.h>
#include<pthread.h>
#include<semaphore.h>
void* read(void* arg);
void* accu(void* arg);
static sem_t sem_one;
static sem_t sem_two;
static int num;
int main(int argc, char* argv[])
{
pthread_t id_t1, id_t2;
sem_init(&sem_one, 0, 0); //生成信号量,初始值为0
sem_init(&sem_two, 0, 1); //生成信号量,初始值为1
pthread_create(&id_t1, NULL, read, NULL);
pthread_create(&id_t2, NULL, accu, NULL);
pthread_join(id_t1, NULL);
pthread_join(id_t2, NULL);
sem_destroy(&sem_one);
sem_destroy(&sem_two);
return 0;
}
void* read(void* arg)
{
int i;
for(i = 0; i < 5; i++)
{
fputs("Input num: ", stdout);
sem_wait(&sem_two); //利用信号量变量sem_two调用wait函数。
scanf("%d", &num);
sem_post(&sem_one); //利用信号量变量sem_one调用post函数。
}
return NULL;
}
void* accu(void* arg)
{
int sum = 0, i;
for(i = 0; i < 5; i++)
{
sem_wait(&sem_one); //利用信号量变量sen_one调用wait函数
sum += num;
sem_post(&sem_two);//利用信号量变量sem_two调用post函数。
}
printf("Result: %d\n", sum);
return NULL;
}如果只用一个信号量会发生什么问题:
- 虽然能保证 read 写 num 的时候 accu 不能读,但无法控制谁先抢到锁。
- 如果 read 连续两次抢到锁,它依然会覆盖上一次的数据。或者 accu 连续两次抢到锁,它会把同一个 num 累加两次。
使用信号量实现生产者和消费者模型:
信号量实现生产者、消费者模型:
/* 信号量实现 生产者 消费者问题 */
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
#include <semaphore.h>
#include <time.h>
#define NUM 5 // 环形队列的容量(柜子总共有 5 个格子)
int queue[NUM]; // 全局数组实现环形队列
sem_t blank_number, product_number; // 空格子信号量,产品信号量
void *producer(void *arg)
{
int i = 0; // 生产者的专属指针(往哪个格子里放)
while (1) {
// 1. 生产者将空格子数 --,为 0 则阻塞等待(等消费者腾出空位)
sem_wait(&blank_number);
// 2. 生产一个产品放进格子里
queue[i] = rand() % 1000 + 1;
printf("----Produce---%d\n", queue[i]);
// 3. 将产品数 ++(顺便唤醒可能在等待产品的消费者)
sem_post(&product_number);
// 4. 借助求余运算实现环形移动
i = (i + 1) % NUM;
sleep(rand() % 3);
}
}
void *consumer(void *arg)
{
int i = 0; // 消费者的专属指针(从哪个格子里拿)
while (1) {
// 1. 消费者将产品数 --,为 0 则阻塞等待(等生产者产出新货)
sem_wait(&product_number);
// 2. 消费一个产品
printf("-Consume---%d\n", queue[i]);
queue[i] = 0; // 拿走以后清零(模拟置空)
// 3. 将空格子数 ++(顺便唤醒可能在等待空位的生产者)
sem_post(&blank_number);
// 4. 借助求余运算实现环形移动
i = (i + 1) % NUM;
sleep(rand() % 3);
}
}
int main(int argc, char *argv[])
{
pthread_t pid, cid;
srand(time(NULL));
// 初始化空格子信号量为 5(一开始全是空位)
// 第二个参数 0 表示线程间共享(非进程间)
sem_init(&blank_number, 0, NUM);
// 初始化产品信号量为 0(一开始一个产品都没有)
sem_init(&product_number, 0, 0);
// 创建线程
pthread_create(&pid, NULL, producer, NULL);
pthread_create(&cid, NULL, consumer, NULL);
// 回收线程
pthread_join(pid, NULL);
pthread_join(cid, NULL);
// 销毁信号量
sem_destroy(&blank_number);
sem_destroy(&product_number);
return 0;
}
用信号量给共享内存加锁:
示例:
// demo3.cpp,本程序演示用信号量给共享内存加锁。
#include "_public.h"
struct stgirl // 超女结构体。
{
int no; // 编号。
char name[51]; // 姓名,注意,不能用string。
};
int main(int argc,char *argv[])
{
if (argc!=3) { cout << "Using:./demo no name\n"; return -1; }
// 第1步:创建/获取共享内存,键值key为0x5005,也可以用其它的值。
int shmid=shmget(0x5005, sizeof(stgirl), 0640|IPC_CREAT);
if ( shmid ==-1 )
{
cout << "shmget(0x5005) failed.\n"; return -1;
}
cout << "shmid=" << shmid << endl;
// 第2步:把共享内存连接到当前进程的地址空间。
stgirl *ptr=(stgirl *)shmat(shmid,0,0);
if ( ptr==(void *)-1 )
{
cout << "shmat() failed\n"; return -1;
}
// 创建、初始化二元信号量。
csemp mutex;
if (mutex.init(0x5005)==false)
{
cout << "mutex.init(0x5005) failed.\n"; return -1;
}
cout << "申请加锁...\n";
mutex.wait(); // 申请加锁。
cout << "申请加锁成功。\n";
// 第3步:使用共享内存,对共享内存进行读/写。
cout << "原值:no=" << ptr->no << ",name=" << ptr->name << endl; // 显示共享内存中的原值。
ptr->no=atoi(argv[1]); // 对超女结构体的no成员赋值。
strcpy(ptr->name,argv[2]); // 对超女结构体的name成员赋值。
cout << "新值:no=" << ptr->no << ",name=" << ptr->name << endl; // 显示共享内存中的当前值。
sleep(10);
mutex.post(); // 解锁。
cout << "解锁。\n";
// 查看信号量 :ipcs -s // 删除信号量 :ipcrm sem 信号量id
// 查看共享内存:ipcs -m // 删除共享内存:ipcrm -m 共享内存id
// 第4步:把共享内存从当前进程中分离。
shmdt(ptr);
// 第5步:删除共享内存。
//if (shmctl(shmid,IPC_RMID,0)==-1)
//{
// cout << "shmctl failed\n"; return -1;
//}
}//_public.h头文件
#ifndef __PUBLIC_HH
#define __PUBLIC_HH 1
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/sem.h>
using namespace std;
// 循环队列。
template <class TT, int MaxLength>
class squeue
{
private:
bool m_inited; // 队列被初始化标志,true-已初始化;false-未初始化。
TT m_data[MaxLength]; // 用数组存储循环队列中的元素。
int m_head; // 队列的头指针。
int m_tail; // 队列的尾指针,指向队尾元素。
int m_length; // 队列的实际长度。
squeue(const squeue &) = delete; // 禁用拷贝构造函数。
squeue &operator=(const squeue &) = delete; // 禁用赋值函数。
public:
squeue() { init(); } // 构造函数。
// 循环队列的初始化操作。
// 注意:如果用于共享内存的队列,不会调用构造函数,必须调用此函数初始化。
void init()
{
if (m_inited!=true) // 循环队列的初始化只能执行一次。
{
m_head=0; // 头指针。
m_tail=MaxLength-1; // 为了方便写代码,初始化时,尾指针指向队列的最后一个位置。
m_length=0; // 队列的实际长度。
memset(m_data,0,sizeof(m_data)); // 数组元素清零。
m_inited=true;
}
}
// 元素入队,返回值:false-失败;true-成功。
bool push(const TT &ee)
{
if (full() == true)
{
cout << "循环队列已满,入队失败。\n"; return false;
}
// 先移动队尾指针,然后再拷贝数据。
m_tail=(m_tail+1)%MaxLength; // 队尾指针后移。
m_data[m_tail]=ee;
m_length++;
return true;
}
// 求循环队列的长度,返回值:>=0-队列中元素的个数。
int size()
{
return m_length;
}
// 判断循环队列是否为空,返回值:true-空,false-非空。
bool empty()
{
if (m_length == 0) return true;
return false;
}
// 判断循环队列是否已满,返回值:true-已满,false-未满。
bool full()
{
if (m_length == MaxLength) return true;
return false;
}
// 查看队头元素的值,元素不出队。
TT& front()
{
return m_data[m_head];
}
// 元素出队,返回值:false-失败;true-成功。
bool pop()
{
if (empty() == true) return false;
m_head=(m_head+1)%MaxLength; // 队列头指针后移。
m_length--;
return true;
}
// 显示循环队列中全部的元素。
// 这是一个临时的用于调试的函数,队列中元素的数据类型支持cout输出才可用。
void printqueue()
{
for (int ii = 0; ii < size(); ii++)
{
cout << "m_data[" << (m_head+ii)%MaxLength << "],value=" \
<< m_data[(m_head+ii)%MaxLength] << endl;
}
}
};
// 信号量。
class csemp
{
private:
union semun // 用于信号量操作的共同体。
{
int val;
struct semid_ds *buf;
unsigned short *arry;
};
int m_semid; // 信号量id(描述符)。
// 如果把sem_flg设置为SEM_UNDO,操作系统将跟踪进程对信号量的修改情况,
// 在全部修改过信号量的进程(正常或异常)终止后,操作系统将把信号量恢复为初始值。
// 如果信号量用于互斥锁,设置为SEM_UNDO。
// 如果信号量用于生产消费者模型,设置为0。
short m_sem_flg;//用于互斥锁,该参数设置为SEM_UNDO,因为信号量的初始值为1,表示解锁。如果全部进程都终止了,这把锁应该是解锁状态, 让操作系统跟踪信号量,如果全部进程都终止了,把信号量设置为1。可以防止进程还没来得及解锁就异常终止了。
csemp(const csemp &) = delete; // 禁用拷贝构造函数。
csemp &operator=(const csemp &) = delete; // 禁用赋值函数。
public:
csemp():m_semid(-1){}
// 如果信号量已存在,获取信号量;如果信号量不存在,则创建它并初始化为value。
// 如果用于互斥锁,value填1,sem_flg填SEM_UNDO。
// 如果用于生产消费者模型,value填0,sem_flg填0。
bool init(key_t key,unsigned short value=1,short sem_flg=SEM_UNDO);
bool wait(short value=-1);// 信号量的P操作,如果信号量的值是0,将阻塞等待,直到信号量的值大于0。
bool post(short value=1); // 信号量的V操作。
int getvalue(); // 获取信号量的值,成功返回信号量的值,失败返回-1。
bool destroy(); // 销毁信号量。
~csemp();
};
#endif#include "_public.h"
// 如果信号量已存在,获取信号量;如果信号量不存在,则创建它并初始化为value。
// 如果用于互斥锁,value填1,sem_flg填SEM_UNDO。
// 如果用于生产消费者模型,value填0,sem_flg填0。
bool csemp::init(key_t key,unsigned short value,short sem_flg)
{
if (m_semid!=-1) return false; // 如果已经初始化了,不必再次初始化。
m_sem_flg=sem_flg;
// 信号量的初始化不能直接用semget(key,1,0666|IPC_CREAT)
// 因为信号量创建后,初始值是0,如果用于互斥锁,需要把它的初始值设置为1,
// 而获取信号量则不需要设置初始值,所以,创建信号量和获取信号量的流程不同。
// 信号量的初始化分三个步骤:
// 1)获取信号量,如果成功,函数返回。
// 2)如果失败,则创建信号量。
// 3) 设置信号量的初始值。
// 获取信号量。
if ( (m_semid=semget(key,1,0666)) == -1)
{
// 如果信号量不存在,创建它。
if (errno==ENOENT)
{
// 用IPC_EXCL标志确保只有一个进程创建并初始化信号量,其它进程只能获取。
if ( (m_semid=semget(key,1,0666|IPC_CREAT|IPC_EXCL)) == -1)
{
if (errno==EEXIST) // 如果错误代码是信号量已存在,则再次获取信号量。
{
if ( (m_semid=semget(key,1,0666)) == -1)
{
perror("init 1 semget()"); return false;
}
return true;
}
else // 如果是其它错误,返回失败。
{
perror("init 2 semget()"); return false;
}
}
// 信号量创建成功后,还需要把它初始化成value。
union semun sem_union;
sem_union.val = value; // 设置信号量的初始值。
if (semctl(m_semid,0,SETVAL,sem_union) < 0)
{
perror("init semctl()"); return false;
}
}
else
{ perror("init 3 semget()"); return false; }
}
return true;
}
// 信号量的P操作(把信号量的值减value),如果信号量的值是0,将阻塞等待,直到信号量的值大于0。
bool csemp::wait(short value)
{
if (m_semid==-1) return false;
struct sembuf sem_b;
sem_b.sem_num = 0; // 信号量编号,0代表第一个信号量。
sem_b.sem_op = value; // P操作的value必须小于0。
sem_b.sem_flg = m_sem_flg;
if (semop(m_semid,&sem_b,1) == -1) { perror("p semop()"); return false; }
return true;
}
// 信号量的V操作(把信号量的值减value)。
bool csemp::post(short value)
{
if (m_semid==-1) return false;
struct sembuf sem_b;
sem_b.sem_num = 0; // 信号量编号,0代表第一个信号量。
sem_b.sem_op = value; // V操作的value必须大于0。
sem_b.sem_flg = m_sem_flg;
if (semop(m_semid,&sem_b,1) == -1) { perror("V semop()"); return false; }
return true;
}
// 获取信号量的值,成功返回信号量的值,失败返回-1。
int csemp::getvalue()
{
return semctl(m_semid,0,GETVAL);
}
// 销毁信号量。
bool csemp::destroy()
{
if (m_semid==-1) return false;
if (semctl(m_semid,0,IPC_RMID) == -1) { perror("destroy semctl()"); return false; }
return true;
}
csemp::~csemp()
{
}






