模板: Linux系统编程
来自Waveshare Wiki
gcc编译工具链
gcc工具链中的三个核心组件是gcc-core、Binutils和glibc。
- gcc-core:也称为GCC编译器,它是GCC工具链的核心,包括C、C++、Fortran等多种语言的编译器,可以将源代码编译成目标文件或可执行文件。
- Binutils:它是一个由GNU开发的二进制工具集,包括了汇编器(as)、链接器(ld)、目标文件格式转换工具(objcopy、objdump)、调试器(gdb)等工具,用于创建、修改、调试和分析可执行文件和目标文件。
- glibc:GNU C Library是GCC工具链中的C标准库,提供了基本的系统函数和API,是Linux系统中用户空间程序开发的核心库。
- 这三个组件相互依赖,组成了一个完整的GCC工具链,使得开发者能够方便地进行软件开发和调试。在Linux系统中,这些组件通常是默认安装的,为开发者提供了一个高效的开发环境。
- 在很多情况下会直接用GCC编译器来指代整套GCC编译工具链。
gcc编辑器
- Linux系统下的gcc是一个开源编译器,全称为GNU Compiler Collection,由GNU工程开发。它支持多种编程语言,如C、C++、Objective-C、Fortran、Ada等,能够编译大型、复杂的应用程序。gcc是一个可移植的编译器,支持多种硬件平台,如ARM、X86等。此外,gcc不仅可以作为本地编译器使用,还可以进行跨平台交叉编译。
- 树莓派默认安装gcc,可以使用如下命令查看:
gcc -v
- Linux下gcc编译C文件为可执行文件分为四个步骤: 分别是 预编译、编译、汇编、链接。
预处理
- 在这个阶段,预处理器将对源代码进行处理,去掉注释、展开宏等,生成一个新的文件(通常以.i结尾),主要的处理规则:
- 将所有的 “#define” 删除,并展开所有的宏定义。
- 处理所有的条件预编译指令,比如:" #if #ifdef #elif #else #endif "。
- 处理所有的 “#include” 预编译指令。
- 删除所有的注释 “//” 、 “/* */”。
- 添加行号和文件名标识,以便编译时产生的行号信息以及用于编译错误或警告时能够显示行号。
- 保留所有的“#pragma”编译器指令。
编译
- 在这个阶段,编译器将对把预处理完的文件进行词法分析、语法分析、语义分析及优化后生成相应的汇编代码文件(通常以.s结尾)。
汇编
- 在这个阶段,将汇编代码转换机器可以执行的二进制代码,生成的二进制代码文件通常以.o文件结尾。
链接
- 在链接过程中,链接器会解析函数、变量的引用关系,并将它们与其他目标文件中定义的函数、变量进行匹配,最终生成一个可执行的文件。
gcc编译过程
- gcc使用的命令语法如下:
gcc [选项] 输入的文件名
- gcc的选项有上百个,我们只介绍gcc的一些常见选项:
- 无选项:会在与源文件目录下产生a.out可执行文件。比如gcc test.c,将生成可执行文件a.out。
- -c:指定编译器只编译源文件,生成目标文件,不进行链接。
- -o:指定编译器生成的输出文件名,例如:gcc main.c -o myprogram。
- -Wall:开启所有警告,以便检测潜在的代码问题。
- -O:开启代码优化,以提高生成的代码效率。
- -g:生成调试信息,以便在程序运行时进行调试。
- -I:指定头文件的搜索路径,例如:gcc -I/usr/local/include myfile.c。
- -L:指定库文件的搜索路径,例如:gcc -L/usr/local/lib myfile.c。
- -l:链接指定的库文件,例如:gcc myfile.c -lm。
- -std:指定使用的C++标准,例如:gcc -std=c++11 myfile.cpp。
- -pthread:开启线程支持。
-
编译过程:
#直接编译成可执行文件 gcc test.c -o test #以上命令等价于执行以下全部操作 #预处理,可理解为把头文件的代码汇总成C代码,把*.c转换得到*.i文件 gcc -E test.c -o test.i #编译,可理解为把C代码转换为汇编代码,把*.i转换得到*.s文件 gcc -S test.i -o test.s #汇编,可理解为把汇编代码转换为机器码,把*.s转换得到*.o,即目标文件 gcc -c test.s -o test.o #链接,把不同文件之间的调用关系链接起来,把一个或多个*.o转换成最终的可执行文件 gcc test.o -o test
- 在实际编程中,我们都会让源文件直接生成可执行文件,以下面简单C程序为例:
#include <stdio.h> int main() { printf("hello, waveshare\n"); return 0; }
- 编译并运行:
gcc test.c -o test ./test
交叉编译
- 如果我们希望编译器运行在x86架构平台上,然后编译生成ARM架构的可执行程序,这种编译器和目标程序运行在不同架构的编译过程,被称为交叉编译。
- 安装ARM-GCC:
# 在主机上执行如下命令 sudo apt install gcc-arm-linux-gnueabihf # 安装完成后编译生成ARM架构的可执行程序 arm-linux-gnueabihf-gcc test.c -o test #查看可执行程序具体属于哪个架构 file test
g++编辑器的使用
- g++是GNU组织退出的C++编译器。它编译程序的内部过程和gcc的一样,使用g++编译C++程序会自动链接到C++的标准库,不需要自己手工指定。
Makefile
make工具
- make工具是一个用于自动化构建软件的工具,它通过读取 Makefile 文件中的规则,自动化执行编译、链接和打包等构建过程。
- make工具解决了软件开发过程中手动编译、链接和打包程序的繁琐和复杂性,特别是在大型项目中,由于源代码量大、文件依赖关系复杂等因素,手动编译变得非常困难和容易出错。
- 调用make工具也是十分简单,直接在终端输入 make 命令即可。
Makefile
- Makefile 是一个用于自动化编译程序的文件,包含了一系列规则,指示了如何编译程序,如何生成可执行文件,以及如何清理编译生成的临时文件等。
- makefile 的命令必须是 makefile或者 Makefile。
makefile的显式规则
- 在Makefile中,#代表着注释,这个是不会被编译进去的,指令前必须打一个[Tab]键,按四下空格会报错。
- Makefile的基本语法是:
目标:依赖 (tab)命令
-
假设当前文件夹中有test.c和Makefile两个文件:
-
上述的程序也可以写成:
- 因为 all 依赖 test.o 文件,所以要先执行gcc -c test.c 得到 test.o 文件,然后才可以执行gcc test.c -o test。所以输入 make 命令后执行顺序如下:
- 在 Makefile 文件中,.PHONY 是一个特殊的目标,用于指定伪目标(即不对应实际文件)列表。这些目标通常表示需要执行的命令或操作,而不是生成文件。
- 使用 .PHONY 目标可以告诉 make 工具,在执行伪目标时不需要检查对应的文件是否存在,如果没有使用 .PHONY,在 Makefile 文件中定义的目标名称与文件名称重名时,make 工具可能会出现错误。
- 例如,如果我们想要使用make clean 清除上次的make命令所产生的object文件(后缀为“.o”的文件)及可执行文件,下面是一个 Makefile 示例,其中定义了 .PHONY 目标和伪目标:
自定义变量
- 在 Makefile 中,变量是非常重要的概念。变量可以在 Makefile 中许多地方使用,比如目标、依赖或者命令中。变量的赋值可以使用以下几种方式:
- =:直接赋值。变量的值会在使用时递归展开。例如:VAR = value。
- ?=:如果变量未定义,则为其赋值。例如:VAR ?= value。
- :=:简单赋值。变量的值会在赋值时立即展开。例如:VAR := value。
- +=:追加值。将一个值追加到变量的值的末尾。例如:VAR += value。
预定义变量
-
预定义变量其实就是系统内已经定义好的变量,变量名已经确定了,直接使用的话,使用的是默认值,你也可以重新赋值。下面列出一些常见的:
变量名 含义 CC C编译器的名称,默认值为cc,即默认使用gcc编译器 RM 文件删除程序的名称,默认值为rm -f CFLAGS C编译器(gcc)的选项,无默认值,如-Wall、-g、-o CPP C预编译器的名称,默认值为$(CC) –E CPPFLAGS C预编译的选项,无默认值 CXXFLAGS C++编译器(g++)的选项,无默认值
自动化变量
- 自动变量有具体的含义,不可被修改。下面是一些常见自动变量:
自动变量的引用 含义 $< 第一个依赖文件的名称 $@ 目标文件的完整名称 $^ 所有不重复的目标依赖文件,以空格分开 $? 代表示比目标还要新的依赖文件列表,以空格分隔 -
例如,在工程中有test.c、test.h、main.c和Makefile四个文件,Makefile文件内容如下:
- 上述编写的Makefile虽然可以正常编译运行,但是在项目文件很多的时候,这样编写会变得非常复杂,此时我们就需要使用自动化变量来简化Makefile文件。
- var:变量表示依赖文件test.o和main.o;
- $^:表示所有的依赖文件test.o和main.o,等同于$(var);
- %.o:表示里面所有的.o文件,即test.o和main.o;
- %.c:表示里面所有的.c文件,即test.c和main.c;
- $<:表示test.c和main.c;
- $@:表示由test.c和main.c生成的目标文件test.o和main.o。
Linux系统编程
什么是Linux系统编程
- Linux系统编程是指编写在Linux操作系统上运行的程序,这些程序可以直接与Linux内核进行交互,利用操作系统提供的各种系统调用和库函数来实现各种功能。Linux系统编程通常需要深入了解Linux操作系统的内部机制和实现原理,因为它需要开发人员能够利用系统调用、线程、进程、信号、文件I/O等底层机制来编写高效、稳定和可靠的程序。在Linux系统编程中,开发人员可以利用各种编程语言(如C、C++、Python等)和开发工具来编写程序,包括编辑器、调试器、编译器等。Linux系统编程可以用于各种领域,如网络编程、系统管理、嵌入式系统、安全等。
Linux系统分层结构
- 用户空间:用户空间是Linux系统的最高层,包括所有用户进程和应用程序。在这一层中,用户可以运行各种软件,例如文本编辑器、浏览器、图像处理软件等。此层还包括用户可以访问的文件系统和各种系统服务和工具。
- 系统调用接口:系统调用接口是Linux内核与用户空间之间的接口层。在这一层中,应用程序使用系统调用来向内核发送请求,以获取系统资源或执行特定操作。系统调用接口提供了一组标准的系统调用,例如读写文件、创建进程和管理进程等。
- 内核空间:内核空间是Linux系统的中间层,它包括内核代码和数据结构。在这一层中,内核提供了各种服务和功能,例如进程管理、文件系统管理、网络管理和设备驱动程序等。此层还包括内核的各种子系统和模块,例如内存管理、调度程序、安全模块等。
- 硬件层:硬件层是Linux系统的最底层,它由各种物理设备组成,例如CPU、内存、磁盘、网络接口卡等。在这一层中,内核使用设备驱动程序与硬件设备进行交互,以控制和管理它们。
POSIX标准简介
- POSIX(Portable Operating System Interface,可移植操作系统接口)是由IEEE和Open Group联合制定的一系列标准,旨在定义一组操作系统接口,使得不同的操作系统之间可以实现可移植性。 POSIX标准主要包括以下几个方面:
- 系统调用:定义了一组标准的系统调用接口,包括文件操作、进程管理、信号处理等。
- 库函数:定义了一组标准的库函数接口,包括数学函数、字符串操作函数、时间函数等。
- 命令行接口:定义了一组标准的命令行接口,包括命令行选项、环境变量、标准输入输出等。
- 线程接口:定义了一组标准的线程接口,包括线程创建、同步和销毁等。
- POSIX标准被广泛应用于Unix和类Unix操作系统中,如Linux、macOS等。通过使用POSIX标准,开发者可以写出与具体操作系统无关的程序,提高了程序的可移植性和可重用性。
Linux系统文件I/O
什么是文件I/O
- I/O是指输入/输出(Input/Output)的缩写,在Linux系统中,所有的输入和输出都是通过文件进行的,这也被称为文件I/O。
- Linux文件I/O指的是在Linux操作系统中主存和外部设备(比如硬盘、U盘)进行文件输入和输出的过程,其中数据从设备到内存的过程称为输入,数据从内存到设备的过程叫输出。
Linux文件I/O编程的基本方式
- 标准I/O(stdio):标准I/O函数库提供了一系列高级的文件I/O函数,如fopen、fclose、fread、fwrite等,它们可以帮助程序员更方便地进行文件操作。标准I/O函数库还提供了缓冲区机制,可以提高文件I/O的效率。
- 文件I/O(syscall):系统调用I/O是直接使用系统调用进行文件操作,例如open、read、write、close等函数。与标准I/O函数库不同,系统调用I/O函数没有缓冲区,文件I/O的效率更高。
- 原始I/O(raw I/O):原始I/O是一种低级别的文件I/O,使用read和write函数进行数据读写,可以更加细粒度地控制文件的I/O操作。原始I/O一般用于对特殊设备进行操作,例如磁盘分区、网络设备等等。
- 这三种方式在Linux系统中都可以使用,选择哪一种方式主要取决于具体的应用场景和需要。标准I/O适用于大多数文件I/O操作,可以提高程序的开发效率;系统调用I/O适用于对文件进行更底层的操作,可以提高文件I/O的效率;原始I/O则适用于对特殊设备进行操作。
文件描述符
- 对于文件I/O来说,一切都是通过文件描述符进行的。在Linux系统中,当打开或者创建一个文件的时候,内核向进程返回一个对应的文件描述符(非负整数)。
- 同时还规定一个进程启动的时候,0是标准输入,1是标准输出,2是标准错误。这意味着如果此时去打开一个新的文件,它的文件描述符会是3,再打开一个文件文件描述符就是4......
- POSIX 定义了 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO 来代替 0、1、2。这三个符号常量的定义位于头文件 unistd.h。
打开或创建文件
- Linux提供open函数来打开或者创建一个文件。该函数声明如下:
#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int open(const char *pathname, int flags); int open(const char *pathname, int flags, mode_t mode)
- 参数含义:
- pathname表示文件称,可以包含(绝对相对)路径;
- flags 表示文件打开方式;
- mode用来规定对该文件的所有者、文件的用户组及系统中其他用户的访问权限。
- 如果函数行成功,就返回文件描述符,如果函数执行失败,就返回-1。
-
文件打开的方式flags可以使用下列宏 (当有多个选项时,采用“|”连接):
- O_RDONLY:打开一个只供读取的文件。
- O_WRONLY: 打开一个只供写入的文件。
- O_RDWR:打开一个可供读写的文件。
- O_APPEND:写入的所有数据将被追加到文件的末尾。
- O_CREAT:打开文件,如果文件不存在就建立文件。
- O_EXCL:如果已经置O_CREAT且文件存在,就强制open失败。
- O_TRUNC:在打开文件时,将文件的内容清空。
-
mode只有创建文件时才使用此参数,指定文件的访问权限。模式有:
- S_IRUSR:文件所有者的读权限位。
- S_IWUSR:文件所有者的写权限位。
- S_IXUSR: 文件所有者的执行权限位。
- S_IRWXU: S IRUSRS IWUSRS IXUSR。
- S_IRGRP:文件用户组的读权限位。
- S_IWGRP.文件用户组的写权限位。
- S_IXGRP:文件用户组的执行权限位。
- S_IRWXG: S_IRGRP|S_IWGRPI|S_IXGRP。
- S_IROTH:文件其他用户的读权限位。
- S_IWOTH:文件其他用户的写权限位。
- S_IXOTH: 文件其他用户的执行权限位。
- S_IRWXO:S_IROTH|S_IWOTH|IXOTH。
- 文件访问权限的计算是根据umask&~mode得出来的。
- 打开当前工作目录waveshare下的文件test.txt:
int fd = open("./waveshare/test.txt", O_RDWR)
- 如果要以只读方式打开当前目录上级目录下的某个文件:
int fd = open("../test.txt", O_RDWR)
- 打开绝对路径下的文件(不存在就创建,否则以读写方式打开):
int fd = open("./waveshare/test.txt", O_CREAT| O_RDWR)
- 创建文件:新建一个open.cpp文件,在open.cpp中输入代码如下:
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int main(int argc, char *argv[]) { int fd; fd = open("waveshare.txt", O_CREAT | O_RDWR, 0666); if (fd == -1) { printf("open is error\n"); return -1; } printf("fd is %d\n", fd); return 0; }
- 如果函数执行成功,返回的文件描述符为3;否则返回-1。
-
编译程序并执行:
g++ -o open open.cpp ./open
关闭文件
- 如果每次打开文件后不关闭,就会将系统的文件描述符耗尽,导致不能再打开文件。
- Linux提供close函数来关闭文件,关闭后,系统可以复用此文件描述符,该函数声明如下:
#include <unistd.h> int close(int fd);
- 关闭文件:新建一个close.cpp文件,在close.cpp中输入代码如下:
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int main(int argc, char *argv[]) { int fd; fd = open("waveshare.txt", O_CREAT | O_RDWR, 0666); if (fd == -1) { printf("open is error\n"); return -1; } printf("fd is %d\n", fd); close(fd); return 0; }
读取文件中的数据
- Linux提供read函数从打开的文件中读取数据,该函数声明如下:
#include<unistd.h> ssize_t read(int fd, void * buf ,size_t count)
- 参数含义:
- fd为文件描述符;
- buf表示读出数据缓冲区地址;
- count表示读出的字节数。
- 函数含义:把参数fd所指的文件传送count个字节到buf指针所指的内存中。
- 返回值:
- 若读取成功,则返回实际读到的字节数;
- 若失败,返回-1;
- 若已达到文件尾或没有可读取数据,则返回0。
- 注意:因此读到的字节数可能小于count的值。
- 从文件中读取数据:
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int main(int argc, char *argv[]) { int fd; char buf[32] = {0}; ssize_t ret; fd = open("waveshare.txt", O_RDWR); if (fd == -1) { printf("open is error\n"); return -1; } printf("fd is %d\n", fd); ret = read(fd,buf,32); if (ret == -1) { printf("read is error\n"); return -2; } printf("buf is %s", buf); printf("ret is %ld\n", ret); close(fd); return 0; }
向文件写入数据
- Linux提供write函数将数据写入已打开的文件内,该函数声明如下:
#include<unistd.h> ssize_t write(int fd, void * buf ,size_t count)
- 参数含义:
- fd为文件描述符;
- buf表示读出数据缓冲区地址;
- count表示读出的字节数。
- 函数含义:把参数buf所指的缓冲区中的count个字节数数据写入fd所指的文件内。
- 向文件中写入数据:
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <cstring> int main(int argc, char *argv[]) { int fd; ssize_t ret; char buf[] = "boys and girls\n hi,children!"; char filename[] = "waveshare.txt"; fd = open(filename,O_RDWR|O_APPEND); if (fd == -1) { printf("open is error\n"); return -1; } printf("fd is %d\n", fd); ret = write(fd,buf,strlen(buf)); printf("write %d bytes to file %s\n",ret,filename); close(fd); return 0; }
文件偏移量
- 有的时候需要从文件中某个位置开始读写,此时需要让文件读写位置移动到新的位置。
- 在Linux系统中可使用函数 lseek 来修改文件偏移量 ( 读写位置 ),该函数声明如下:
#include <sys/types.h> #include<unistd.h> off_t lsweek(int fd, off_t offset, int whence)
- 参数含义:
- fd为文件描述符;
- offset:偏移量,以字节为单位,offset 可以为正、也可以为负,如果是正数表示往后偏移,如果是负数则表示往前偏移。
- whence:与参数offset偏移量搭配使用,该参数为当前位置的基点,可以使用一下三种值:
- SEEK_SET:相对于文件开头;
- SEEK_CUR:相对于当前的文件读写指针位置;
- SEEK_END:相对于文件末尾。
- 把文件位置指针设置为100:
lseek(fd,100,SEEK SET);
-
把文件位置设置成文件末尾:
lseek(fd,0,SEEK END);
- 确定当前的文件位置:
lseek(fd,0,SEEK CUR);
- 第二次重新开始读取文件:
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int main(int argc, char *argv[]) { int fd; char buf[32] = {0}; ssize_t ret; fd = open("waveshare.txt", O_CREAT | O_RDWR, 0666); if (fd == -1) { printf("open is error\n"); return -1; } // printf("fd is %d\n", fd); ret = read(fd, buf, 32); if (ret < 0) { printf("read is error\n"); return -2; } printf("buf is %s\n", buf); printf("ret is %ld\n", ret); lseek(fd,0,SEEK_SET); ret = read(fd, buf, 32); printf("ret is %ld\n", ret); close(fd); return 0; }
- 确定文件的当前位置:
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int main(int argc, char *argv[]) { int fd; char buf[32] = {0}; ssize_t ret; fd = open("waveshare.txt", O_CREAT | O_RDWR, 0666); if (fd == -1) { printf("open is error\n"); return -1; } ret = read(fd, buf, 8); if (ret < 0) { printf("read is error\n"); return -2; } printf("buf is %s\n", buf); printf("ret is %ld\n", ret); ret=lseek(fd,0,SEEK_CUR); printf("ret is %ld\n", ret); close(fd); return 0; }
- 查看文件的字节数:
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int main(int argc, char *argv[]) { int fd; char buf[32] = {0}; ssize_t ret; fd = open("waveshare.txt", O_CREAT | O_RDWR, 0666); if (fd == -1) { printf("open is error\n"); return -1; } ret = read(fd, buf, 2); if (ret < 0) { printf("read is error\n"); return -2; } printf("buf is %s\n", buf); printf("ret is %ld\n", ret); ret=lseek(fd,0,SEEK_END); printf("ret is %ld\n", ret); close(fd); return 0; }
多进程编程
进程的基本概念
- 进程是操作系统中一个正在执行中的程序实例,它拥有自己的执行状态、数据空间和系统资源(例如CPU时间、内存、文件描述符等)。每个进程都有一个唯一的进程标识符(PID),可以用来唯一地标识和管理进程。进程可以独立运行,也可以与其他进程协作。进程可以通过fork()系统调用创建子进程,并使用exec()系统调用来加载和执行新的程序。进程还可以使用信号来与其他进程进行通信和同步。进程是操作系统中的基本执行单元,操作系统通过进程调度算法来分配CPU时间和系统资源,以实现多任务处理和并发执行。多个进程可以同时运行在操作系统上,每个进程都有自己的地址空间和执行环境,互不干扰。操作系统通过进程管理机制来确保进程之间的安全和稳定性,防止进程之间互相干扰和破坏系统的稳定性。
- 程序是一组指令或代码,用于执行特定的任务或完成特定的工作。通常,程序被编写成源代码形式,需要通过编译器或解释器将其转换为可执行代码才能在计算机上运行。
- 进程是正在执行的程序的实例。进程包括了程序的代码、数据和当前的执行状态。在操作系统中,每个进程都有其自己的进程标识符(PID),用于唯一地标识该进程。 操作系统利用进程调度算法来控制进程的执行顺序,以实现并发执行多个进程的目的。
- 简单来说,程序是静态的,它们只是一组指令,而进程则是动态的,它们是正在执行的程序的实例。程序只有在被加载到内存中并执行时才会成为进程。
结合实例理解进程
- 在Windows系统中我们可以通过任务管理器来进行进程管理,在 Linux 系统中,需要每天监视系统进程的运行情况并适时终止一些失控的进程。
- Linux 中虽然使用命令进行进程管理,但是进程管理的主要目的是一样的,即查看系统中运行的程序和进程、判断服务器的健康状态和强制中止不需要的进程。
- 一个常见的生活实例是在电脑上打开一个程序,比如打开一个文本编辑器。当你双击文本编辑器图标时,操作系统会为该程序创建一个进程,该进程拥有自己的执行状态、数据空间和系统资源。这个进程运行在操作系统的进程管理机制下,并通过操作系统来调度CPU时间和系统资源。当你在文本编辑器中输入文本并保存时,该进程会将数据保存到磁盘上的文件中。在这个例子中,打开文本编辑器的过程就是创建了一个进程。该进程负责执行文本编辑器程序的代码,并占用一些系统资源(例如内存、CPU时间等)。这个进程的运行状态可以通过操作系统提供的工具(例如任务管理器)来监视和管理。如果程序出现问题或不响应,你可以通过操作系统提供的工具来终止该进程,以恢复系统的正常运行。
进程的状态
- 在单任务操作系统中,CPU 只能同时执行一个进程,当一个进程在运行时,其他进程必须等待该进程执行完毕才能开始运行。
- 在多任务操作系统中,CPU 可以同时处理多个进程。当一个进程被分配 CPU 时间片时,它就可以运行,并且其他进程则会在就绪队列中等待下一个时间片。
- 多任务操作系统可以同时运行多个进程,通过调度器选择进程执行,并通过上下文切换实现进程之间的切换。而单任务操作系统只能处理一个进程,并使用简单的调度算法来决定运行哪个进程。
- 多任务操作系统中,进程在执行过程中可能会经历多种状态
- 开始状态:当一个进程被创建时,它处于初始状态,此时操作系统为进程分配必要的资源和空间。
- 就绪状态:当进程获得了所有必需的资源,并且等待 CPU 时间时,它被称为就绪状态。此时进程已经准备好运行,只需等待 CPU 调度它即可执行。
- 运行状态:当进程被 CPU 执行时,它处于运行状态。在此状态下,进程使用 CPU 时间来执行其指令,并且能够访问其分配的资源。
- 阻塞状态:当进程在执行过程中等待某个事件发生时,例如等待用户输入或等待某个文件被读入,它被称为阻塞状态。此时进程不会占用 CPU 时间,但也无法继续执行,直到事件发生并且进程再次变为就绪状态。
- 结束状态:当进程完成其执行或者因为某种原因被终止时,它被称为终止状态。此时进程被删除,并释放其分配的资源。
- 以上五种状态构成了进程的生命周期,进程在这些状态之间转移,直到最终结束。
进程控制块
- 进程由进程控制块、有关程序段和操作的数据集三部分组成。
- 进程控制块(Process Control Block,PCB)是操作系统实现进程管理的关键数据结构之一,它保存了操作系统用于控制和管理进程所需的所有信息。操作系统可以利用 PCB 中的信息来实现进程调度、进程同步和进程通信等功能。
- PCB通常被保存在操作系统的内核中,操作系统可以利用PCB来维护进程的状态和控制进程的执行。每个进程的PCB中的信息是唯一的,这样操作系统就可以正确地识别和管理每个进程。
- 当创建一个进程时,系统首先创建其PCB,然后根据PCB中的信息实施有效的管理和控制。当一个进程完成功能后,系统则释放PCB,进程也随之消亡。
- 操作系统在处理进程时所需的全部信息,通常包括以下内容:
- 进程标识符(Process ID,PID):每个进程都有一个唯一的PID,用于区分不同的进程。
- 进程状态:表示进程目前所处的状态,如就绪、运行、阻塞等。
- 寄存器:保存了进程运行时的寄存器值,包括通用寄存器、程序计数器、堆栈指针等。
- 进程优先级:用于确定进程在就绪队列中的优先级,以便操作系统按照优先级调度进程。
- 进程调度信息:包括进程的时间片大小、已用时间片数等信息。
- 进程等待队列指针:指向进程等待队列中下一个进程的PCB。
- 进程打开文件列表:记录了进程打开的文件和文件描述符。
- 进程内存管理信息:包括进程使用的内存空间的起始地址、大小等信息。
- 进程使用的资源:记录进程所使用的各种资源,如打开的文件、内存空间、IO设备等等。
- 进程通信信息:记录进程与其他进程之间通信的信息,如消息队列、管道、共享内存等等。
- 父进程和子进程关系:记录进程的父进程和子进程的关系,用于实现进程间的通信和协调。
进程的标识符
- 进程标识符是操作系统中用于标识每个进程的唯一标识符,通常被称为PID(Process ID)。PID是一个整数值,它在操作系统中是唯一的。
- 通常情况下,操作系统会按照一定的规则为每个进程分配一个唯一的PID,在进程运行时,PID是不会变化的,进程终止后,PID被系统回收,后面会重新分配给新运行的进程。
ps
- 在Linux系统中,可以使用ps命令来列出系统中当前运行的进程。
- 常用命令:
ps aux可以查看系统中所有的进程
ps -le可以查看系统中所有的进程,而且还能看到进程的父进程的 PID 和进程优先级
ps -ef
- a:显示一个终端的所有进程,除会话引线外;
- u:显示进程的归属用户及内存的使用情况;
- x:显示没有控制终端的进程;
- -l:长格式显示更加详细的信息;
- -e:显示所有进程;
- f:用ASCII字符显示树状结构,表达程序间的相互关系。
- ps aux和ps -ef的区别:
- ps 命令输出信息含义:
USER/UID 该进程是由哪个用户产生的。 PID 进程的 ID。 PPID 父进程的进程号。 C: 进程生命周期中的CPU利用率 STIME: 进程启动时的系统时间 %CPU 进程占用 CPU 资源的百分比。 %MEM 进程占用物理内存的百分比。 VSZ 该进程占用虚拟内存的大小,单位为 KB。 RSS 该进程占用实际物理内存的大小,单位为 KB。 TTY 该进程是在哪个终端运行的。 STAT 进程状态。常见的状态有以下几种: -D:无法中断的休眠状态 (通常 IO 的进程) -R:正在运行进程。 -S:进程处于睡眠状态。 -T:停止状态,可能是在后台暂停或进程处于除错状态。 -W:内存交互状态(从 2.6 内核开始无效)。 -Z:僵尸进程。进程已经中止,但是部分程序还在内存当中。 -<:高优先级。 -N:低优先级。 -L:有些页被锁入内存。 -s:包含子进程。 -l:多线程(小写 L)。 -+:位于后台。 START 该进程的启动时间。 TIME 该进程占用 CPU 的运算时间,注意不是系统时间。 COMMAND 产生此进程的命令名。
top
- ps命令是用于静态地查看系统中进程的信息,如果想要实时动态地查看系统中进程的情况,就可以使用top命令
- 参数:
- -d:改变显示的更新速度,指定 top 命令每隔几秒更新。默认是 3 秒;
- -b:使用批处理模式输出。一般和"-n"选项合用,可以用来将 top 的结果输出到档案内;
- -n:更新的次数,完成后将会退出 top;
- -p:进程PID:仅查看指定 ID 的进程;
- -s:使 top 命令在安全模式中运行,避免在交互模式中出现错误;
- -u:用户名:只监听某个用户的进程;
- -c: 切换显示模式,显示完整的路径与名称;
- -q:没有任何延迟的显示速度。
- 常用命令:
top 显示进程信息 top -c 显示完整命令 top -b 以批处理模式显示程序信息 top -S 以累积模式显示程序信息 top -n 2 设置信息更新次数,更新两次后停止更新 top -p 139 显示指定的进程信息 top -n 10 显示更新十次后退出
- 在 top 命令的显示窗口中,还可以使用如下按键,进行一下交互操作:
- ? 或 h:显示交互模式的帮助;
- P:按照 CPU 的使用率排序,默认就是此选项;
- M:按照内存的使用率排序;
- N:按照 PID 排序;
- T:按照 CPU 的累积运算时间排序,也就是按照 TIME+ 项排序;
- k:按照 PID 给予某个进程一个信号。一般用于中止某个进程,信号 9 是强制中止的信号;
- r:按照 PID 给某个进程重设优先级(Nice)值;
- q:退出 top 命令;
kill
- 在 Linux 系统中,kill 命令用于向指定进程发送信号,以便控制进程的行为。其基本语法如下:
kill [signal] PID...
- signal 参数用于指定要发送的信号类型,可以是信号名称或信号编号;
- PID 参数用于指定要发送信号的进程标识符,可以是一个或多个进程标识符,多个进程标识符之间用空格分隔。
- kill命令常用信号及其含义:
信号编号 信号名 含义 0 EXIT 程序退出时收到该信息 1 HUP 挂掉电话线或终端连接的挂起信号,这个信号也会造成某些进程在没有终止的情况下重新初始化 2 INT 表示结束进程,但并不是强制性的,常用的 "Ctrl+C" 组合键发出就是一个 kill -2 的信号 3 QUIT 退出 9 KILL 杀死进程,即强制结束进程 11 SEGV 段错误 15 TERM 正常结束进程,是 kill 命令的默认信号 -
例如强制杀死进程号为2246的进程:
kill -9 2246
- 更多详细的信号信息可以查看进程间通信的信号部分。
进程的创建
使用fork创建进程
- 在Linux系统中可以通过fork函数来创建新进程,由fork创建的新进程被称为子进程。
- 子进程几乎与原始进程相同,包括代码、数据和打开的文件描述符。但是,它们并不共享内存,子进程的进程ID(PID)不同于父进程的PID。子进程的PID由操作系统分配并且是唯一的。系统调用fork函数声明如下:
#include <unistd.h> pid_t fork();
- 返回值:
- 如果创建成功,在父进程的程序中fork函数将返回子进程的PID;
- 如果创建成功,在子进程中fork函数则返回0;
- 如果创建失败,fork返回一个负值。
- 通过fork创建子进程:
#include <stdio.h> #include <unistd.h> int main(void) { pid_t pid; pid = fork(); if (pid < 0) { printf("fork is error \n"); return -1; } //父进程 if (pid > 0) { printf("This is parent,parent pid is %d\n", getpid()); } //子进程 if (pid == 0) { printf("This is child,child pid is %d,parent pid is %d\n",getpid(),getppid()); } return 0; }
- getpid():获得当前进程的PID;
- getppid():获得当前进程父进程的PID。
-
编译程序并执行:
gcc -o fork fork.c ./fork
使用exec创建进程
- 在Linux系统中,exec是一组用于执行其他程序的系统调用函数。exec函数可以将当前进程替换为另一个进程,从而实现程序的动态加载和替换。
- exec()函数族:
#include <unistd.h> 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函数声明:
#include <unistd.h> int execl(const char *path, const char *arg, ...);
- 参数含义:
- path:指向要执行的文件路径;
- arg以及后面省略号:代表执行该程序时传递的参数列表,path后面参数是argv[0],第二个是argv[1],对于系统命令程序,比如ls命令,argv[0]是必须要有的,但是其值可以是一个无意义的字符串。
- 使用execl执行系统命令ls:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main(void) { int i=0; pid_t pid; pid = fork(); if (pid < 0) { printf("fork is error \n"); return -1; } //父进程 if (pid > 0) { printf("This is parent,parent pid is %d\n", getpid()); } //子进程 if (pid == 0) { printf("This is child,child pid is %d\n", getpid(), getppid()); execl("/bin/ls", "lsakakk", "-l", NULL); exit(1); } i++; //printf("i is %d\n",i); return 0; }
孤儿进程和僵尸进程
- 孤儿进程是指父进程在子进程结束之前就已经退出或被杀死,导致子进程成为孤儿进程。孤儿进程会被init进程(进程号为1的特殊进程)接管,并成为init进程的子进程。这是因为所有进程都必须有一个父进程,而init进程是系统启动时第一个运行的进程,所以它没有父进程。当孤儿进程结束时,它的资源会被回收。
- 僵尸进程是指一个已经结束执行的进程,但是它的父进程还没有调用wait()或waitpid()来获取它的退出状态,导致它的进程描述符在系统进程表中仍然存在,但是没有任何进程控制块和内存空间。 僵尸进程不占用CPU时间和内存空间,但是会占用进程表中的一个条目。当系统中存在大量的僵尸进程时,会影响系统性能。可以通过父进程调用wait()或waitpid()来回收僵尸进程的资源。
-
如果不及时处理僵尸进程和孤儿进程,会导致以下后果:
- 僵尸进程会占用系统进程表中的条目,导致系统资源浪费,降低系统的性能。
- 孤儿进程没有父进程来管理和控制,可能会导致资源泄露或者系统崩溃。
- 如果大量的僵尸进程和孤儿进程堆积在系统中,会导致系统进程表满,无法再创建新的进程。
- 由于孤儿进程会被init进程接管,如果init进程也出现问题,可能会导致系统崩溃或无法正常运行。
- 因此,处理僵尸进程和孤儿进程非常重要。对于僵尸进程,可以通过父进程调用wait()或waitpid()来回收资源;对于孤儿进程,则需要及时杀死或者让其结束,并将其资源释放回系统。
- wait函数声明:
#include <sys/wait.h> pid t wait(int *status)
- 返回值: 成功返回回收的子进程的pid,失败返回-1
- 与wait函数的参数有关的俩个宏定义:
- WIFEXITED(status): 若该宏定义为真,表明子进程正常结束。
- WEXITSTATUS(status): 如果子进程正常退出,则该宏定义的值为子进程的退出值。
if(WIFEXITED(status)) { printf("退出值为 %d\n", WEXITSTATUS(status)); }
- 示例程序:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> int main(void) { int i=0; pid_t pid; pid = fork(); if (pid < 0) { printf("fork is error \n"); return -1; } if (pid > 0) { int status; wait(&status); if(WIFEXITED(status)==1) { printf("return value is %d\n",WEXITSTATUS(status)); } } if (pid == 0) { sleep(2); printf("This is child\n"); exit(6); } return 0; }
进程调度
- Linux进程调度是指操作系统内核如何决定哪个进程应该被运行的过程。Linux进程调度的目标是提高系统的响应性、吞吐量和公平性。在Linux中,进程调度的核心是一个调度器,它负责决定哪个进程应该获得CPU时间片。
- Linux的进程调度是基于时间片轮转算法实现的。每个进程被分配一个时间片,在时间片到期后,调度器会把当前运行的进程挂起,并把CPU时间片分配给下一个等待运行的进程。 这种轮流分配CPU时间片的方式保证了每个进程都有机会运行,而且不会一直占用CPU资源,从而导致其他进程无法运行。
- Linux中进程调度的优先级是动态调整的。每个进程都被赋予一个优先级,该优先级取决于进程的调度策略、进程的实时性要求以及进程的历史行为。根据优先级,调度器决定哪个进程应该获得CPU时间片。
- Linux的进程调度策略有两种:时间片轮转调度和实时调度。时间片轮转调度是默认的调度策略,适用于大多数应用程序。实时调度适用于对响应时间有严格要求的应用程序,如控制系统和嵌入式系统。
- Linux的进程调度是一个复杂的系统,涉及到许多因素,如进程的状态、优先级、资源需求等。
进程的分类
- 在Linux系统中,进程一般分为前台进程、后台进程和守护进程3类。
守护进程
- 守护进程是后台运行的一种特殊进程,它通常不与用户直接交互,也不受用户登录或注销的影响。它是为了完成某种特定的任务而运行的,例如提供服务或者监控系统状态。
- Linux系统的大多数服务器就是通过守护进程实现的。常见的守护进程包括系统日志进程syslogd、 web服务器httpd、邮件服务器sendmail和数据库服务器mysqld等。
- 守护进程通常在系统启动时开始运行,并以超级用户权限运行。它们经常需要访问特殊的资源或使用特殊的端口(1-1024)。守护进程会一直运行直到系统关机,除非被强制终止。它们的父进程是init进程,因为它们的真正父进程在fork出子进程后就先于子进程exit退出了。因此,它们是由init继承的孤儿进程。由于守护进程是非交互式程序且没有控制终端,任何输出都需要特殊处理。通常,守护进程的名称以d结尾,例如sshd、xinetd和crond。
编写守护进程的步骤
- 进程组:一个或多个进程的集合,进程组由进程组ID标识,进程组长的进程ID和进程组ID一致,并且进程组ID不会由于进程组长的退出而受到影响
- 会话周期:一个或多个进程组的集合,比如用户从登陆到退出,这个期间用户运行的所有进程都属于该会话周期
- setsid函数:创建一个新会话,并担任该会话组的组长,调用setsid函数的目的:让进程摆脱原会话,原进程组,原终端的控制
- 创建子进程,父进程退出
- 在前面我们学习过,当父进程先于子进程退出会造成子进程变成孤儿进程,然后由1号init进程收养它,这样此子进程就变成了init进程的子进程。
- 子进程创建新会话
- 调用setsid创建新的会话,摆脱原会话,原进程组,原终端的控制,自己成为新会话的组长。
- 将当前目录改为根目录
- 正在运行的进程文件系统(如“/mnt/usb”)不能卸载,如果目录要回退,则此时进程不能做到,为了避免这种麻烦,通常以根目录作为守护进程当前目录,改变工作目录的常见函数是chdir。
- 重设文件权限掩码
- 子进程的文件权限掩码是复制的父进程的,不重新设置的话,会给子进程使用文件带来诸多麻烦,设置文件掩码的函数是umask,这里使用umask(0)来增强守护进程的灵活性。
- 关闭不需要的文件描述符
- 子进程的文件描述符也是从父进程复制来的,那些不需要的文件描述符永远不会被守护进程使用,会白白的浪费系统资源,还可能导致文件系统无法结束。
- 守护进程退出处理
- 当用户需要外部停止守护进程运行时,往往会使用 kill 命令停止该守护进程。所以,守护进程中需要编码来实现 kill 发出的signal信号处理,达到进程的正常退出。
- 创建一个守护进程:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> #include <sys/types.h> #include <sys/stat.h> int main(void) { pid_t pid; // 步骤一:创建一个新的进程 pid = fork(); //父进程直接退出 if (pid > 0) { exit(0); } if (pid == 0) { // 步骤二:调用setsid函数摆脱控制终端 setsid(); // 步骤三:更改工作目录 chdir("/"); // 步骤四:重新设置umask文件源码 umask(0); // 步骤五:0 1 2 三个文件描述符 for (int i = 1; i < 4; i++) { close(i); } while (1) { } } return 0; }
进程间的通信
信号
- 在 Linux 系统中,信号是一种异步通知机制,用于在进程之间或者操作系统和进程之间传递事件和信息。进程可以通过信号来响应外部事件,例如用户输入、硬件异常、其他进程的操作等。
-
用户进程对信号的响应有三种方式:
- 忽略信号:对信号不做任何处理。但是有两个信号是不能忽略的,即 SIGKILL 和 SIGSTOP。SIGKILL 用于立即终止进程的执行,SIGSTOP 用于暂停进程的执行。
- 捕捉信号:定义信号处理函数,当信号发送时,执行相应的自定义处理函数。应用程序可以使用 signal() 或者 sigaction() 等系统调用来注册信号处理函数,以响应特定的信号。
- 执行默认操作:Linux 对每种信号都规定了默认操作。例如,当进程收到 SIGTERM 信号时,Linux 默认会终止该进程的执行。应用程序可以选择不定义信号处理函数,而让系统执行默认操作来响应特定的信号。
-
信号从产生到处理的过程:
- 信号的产生:信号可以由多种事件触发,例如硬件中断、软件异常、用户按下某个键等。当事件发生时,Linux 内核会自动产生相应的信号,并将其发送给目标进程。
- 信号的传递:产生信号的进程将信号发送给一个特定的目标进程,通常使用系统调用 kill() 或者 sigqueue() 来发送信号。发送信号的进程需要知道目标进程的进程 ID(PID),并且需要指定信号类型和其他参数。
- 信号的接收:目标进程接收到信号,操作系统会检查该进程对该信号的处理方式。如果该信号已经被阻塞或者忽略,那么操作系统会将信号保存在进程的信号队列中,等待目标进程解除对该信号的阻塞或忽略后再进行处理。
- 信号的处理:如果目标进程没有对该信号进行特殊处理,或者该信号没有被阻塞或忽略,那么操作系统就会调用目标进程的信号处理函数来处理该信号。每个进程可以设置自己的信号处理函数,当进程接收到信号时,操作系统会自动调用相应的信号处理函数。
- 信号的处理方式:每个进程可以设置自己的信号处理方式,包括信号的处理函数、信号的阻塞方式、信号的忽略方式等。有些信号不能被阻塞或忽略,例如 SIGKILL 和 SIGSTOP 信号。
- 信号的优先级:Linux 中的信号具有优先级,数字越小的信号优先级越高,例如 SIGKILL 的优先级为 9,而 SIGINT 的优先级为 2。当进程同时收到多个信号时,操作系统会根据信号的优先级来决定先处理哪个信号。
- 信号的默认处理方式:对于每种信号类型,Linux 内核都定义了一种默认的处理方式。例如,对于 SIGINT 信号(通常由用户在终端上按下 Ctrl+C 产生),默认处理方式是终止目标进程。但是,进程可以通过调用 sigaction 等函数来设置自己的信号处理方式,包括设置信号的处理函数、阻塞或忽略信号等。
-
如果想要查看Linux中已经定义好的信号,在终端输入:
kill -l
-
系统中常用的信号:
信号 信号值 程序默认行为 说明 SIGHUP 1 终止运行 进程的控制终端关闭 SIGINT 2 终止运行 用户产生中断符(Ctrl-C) SIGQUIT 3 终止且进行内存转储 用户产生退出符(Ctrl-\) SIGILL 4 终止且进行内存转储 进程试图执行非法指令 SIGTRAP 5 终止且进行内存转储 进入断点 SIGABRT 6 终止且进行内存转储 来自abort()函数的终止信号 SIGBUS 7 终止且进行内存转储 硬件或对齐错误 SIGFPE 8 终止且进行内存转储 算术异常 SIGKILL 9 终止运行 强制杀死进程,程序无法对该信号进行定制处理 SIGUSR1 10 终止运行 进程自定义的信号1 SIGSEGV 11 终止且进行内存转储 无效内存访问 SIGUSR2 12 终止运行 进程自定义的信号2 SIGPIPE 13 终止 向无读取进程的管道写入 SIGALRM 14 终止进行 由alarm()发送 SIGTERM 15 终止 可以捕获的进程终止信号 SIGSTKFLT 16 终止(b) 协处理器栈错误 SIGCHLD 17 忽略 子进程终止 SIGCONT 18 忽略 进程停止后继续执行 SIGSTOP 19 停止 挂起进程 SIGSTP 20 停止 用户生成挂起操作符(Ctrl-Z) SIGTTIN 21 停止 后台进程从控制终端读 SIGTTOU 22 停止 后台进程从控制终端写 SIGURG 23 忽略 紧急I/O未处理 SIGXCPU 24 终止且进行内存转储 超过CPU时间资源限制 SIGXFSZ 25 终止且进行内存转储 超过文件大小资源限制 SIGVTALRM 26 终止 计算该进程占用CPU的时间 SIGPROF 27 终止 向无读取进程的管道写入 SIGWINCH 28 忽略 控制终端窗口大小改变 SIGIO 29 终止(a) 异步IO事件(Ctrl-C) SIGPWR 30 终止 断电 SIGSYS 31 终止且进行内存转储 进程试图执行无效系统调用 - 发送信号的函数主要有kill(),raise(),alarm(),pause()
- kill()函数声明:
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);
- pid 参数指定要接收信号的进程 ID;
- sig 参数指定要发送的信号类型。
kill()函数实例:
#include <signal.h> #include <stdlib.h> int main(int argc,char *argv[]) { pid_t pid; int sig; if(argc < 3){ printf("Usage:%s <pid_t> <signal>\n",argv[0]); return -1; } //字符串转整型 sig = atoi(argv[2]); pid = atoi(argv[1]); kill(pid,sig); return 0; }
编译命令并运行:gcc kill.c -o kill ./kill
- raise()函数可以让进程向自己发送指定的信号,函数声明:
#include <signal.h> int raise(int sig);
- sig 参数指定要发送的信号类型。
#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <signal.h> #include <stdlib.h> int main(void) { printf("raise before\n"); raise(9); printf("raise after\n"); return 0; }
- 程序打印出开始,使用raise()函数发送停止信号,进程结束。
-
alarm() 函数可以在指定的时间之后向当前进程发送一个SIGALRM信号,用于定时操作。函数声明:
#include <unistd.h> unsigned int alarm(unsigned int seconds);
- seconds 参数指定定时的秒数。alarm() 函数会在指定的秒数之后向当前进程发送一个 SIGALRM 信号。
alarm()函数实例:
#include <stdio.h> #include <unistd.h> #include <signal.h> void handler(int sig) { printf("Received signal %d\n", sig); } int main() { signal(SIGALRM, handler); // 注册信号处理函数 alarm(5); // 设置定时器,5秒后触发 SIGALRM 信号 printf("Waiting for alarm to go off...\n"); pause(); // 阻塞进程,等待信号触发 printf("Exiting...\n"); return 0; }
编译命令并运行:gcc alarm.c -o alarm ./alarm
- 在上面的示例程序中,我们首先注册了 SIGALRM 信号的处理函数 handler(),然后调用 alarm(5) 函数来设置一个 5 秒的定时器。在等待定时器触发的过程中,我们使用 pause() 函数来阻塞进程,等待信号触发。当定时器触发后,会自动向进程发送 SIGALRM 信号,从而触发信号处理函数,程序就会打印出收到的信号号码,并退出。
-
信号的接收:
- 接收信号:如果要让我们接收信号的进程可以接收到信号,那么这个进程就不能停止,一般使用while、sleep和pause。
-
Linux系统调用signal()来为信号设置一个新的信号处理程序,可以将这个信号处理程序设置为一个用户指定的函数,函数声明如下:
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
- signum:我们要进行处理的信号,系统的信号我们可以再终端键入 kill -l查看。
- handler:处理的方式(是系统默认还是忽略还是捕获)。
*signal(SIGINT ,SIG_IGN); //SIG_IGN, 代表忽略,也就是忽略SIGINT信号,SIGINT信号由InterruptKey产生,通常是用户按了Ctrl+C键或者Delete键产生。 *signal(SIGINT ,SIG_DFL); //SIG_DFL代表执行系统默认操作,大多数信号的系统默认动作时终止该进程。 *signal(SIGINT ,handler); //捕捉SIGINT这个信号,然后执行handler函数里面的代码。handler由我们自己定义。
- 自定义信号SIGINT的处理:
#include <stdio.h> #include <signal.h> void signal_handler_fun(int signum) { printf("catch signal %d\n", signum); } int main(int argc, char *argv[]) { signal(SIGINT, signal_handler_fun); while (1); return 0; }
- 可以看到每次按下Ctrl+C键的时候,都会执行signal_handler_fun函数,而不是退出程序。
管道
- 在 Linux 系统中,管道用于连接读进程和写进程,以实现它们之间通信的共享文件,故又称管道文件。
-
在 Linux 中,管道可以分为两种类型:匿名管道和命名管道。
- 匿名管道(Anonymous Pipes):匿名管道是一种简单的半双工管道,只能用于在具有亲缘关系的进程之间进行通信。匿名管道创建后,它就成为两个进程之间的共享文件描述符,其中一个进程通过管道写入数据,另一个进程通过管道读取数据。匿名管道的生命周期与进程相关联,当创建它的进程终止后,匿名管道也被销毁。
- 命名管道(Named Pipes):命名管道也称为FIFO,是一种特殊类型的文件,它可以允许任何进程在任何时间通过文件名来访问它。它提供了一种无关进程亲缘关系的通信机制,可以允许多个进程同时访问它,并且可以用于在网络中进行进程通信。命名管道会一直存在于文件系统中,直到它被删除或系统关闭。任何有权限访问命名管道的进程都可以向其中写入数据或读取数据。
- 综上所述,匿名管道适用于亲缘关系进程之间的通信,而命名管道则适用于非亲缘关系进程之间的通信,且命名管道具有持久性。
-
匿名管道的特点:
- 管道采用半双工通信方式,因此数据只能单向传输。
- 管道其实是一个固定大小的缓冲区,如果一个进程向已满的管道写入数据,系统会阻塞该进程,直到管道能有空间接收数据。
- 管道通信机制必须能够提供读写进程之间的同步机制。
-
创建匿名管道使用pipe()函数,函数声明:
#include <unistd.h> int pipe(int pipefd[2]);
- 函数参数pipefd是一个包含两个int类型元素的数组,用于返回两个文件描述符;pipefd[0]为读端,pipefd[1]为写端。在调用pipe()函数后,两个进程可以使用这两个文件描述符进行通信,实现数据的传输。
-
父子进程通过管道通信的实例:
- 第一步:父进程调用pipe()函数创建管道,得到两个文件描述符fd[0]、fd[1],分别指向管道的读端和写端。
- 第二步:父进程使用fork()函数创建子进程,那么子进程也有两个文件描述符指向同一管道。
- 第三步:父进程关闭管道读端,子进程关闭管道写端。父进程可以向管道中写入数据,子进程将管道数据读出。
#include <unistd.h> #include <string.h> #include <stdlib.h> #include <stdio.h> #include <sys/wait.h> void sys_err(const char *str) { perror(str); exit(1); } int main(void) { pid_t pid; char buf[1024]; int fd[2]; char p[] = "test for pipe\n"; if (pipe(fd) == -1) sys_err("pipe"); pid = fork(); if (pid < 0) { sys_err("fork err"); } else if (pid == 0) { close(fd[1]); printf("child process wait to read:\n"); int len = read(fd[0], buf, sizeof(buf)); write(STDOUT_FILENO, buf, len); close(fd[0]); } else { close(fd[0]); write(fd[1], p, strlen(p)); wait(NULL); close(fd[1]); } return 0; }
-
创建命名管道使用mkfifo()函数,函数声明:
#include <sys/types.h> #include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);
- 函数参数pathname是有名管道的路径名,mode是权限掩码。调用mkfifo()函数会在指定路径名创建一个有名管道,返回值为0表示创建成功,-1表示创建失败。
- 读写有名管道时可以使用普通的read()和write()函数,也可以使用open()函数打开管道文件,然后使用read()和write()函数进行读写。当然,在使用完有名管道后,需要使用unlink()函数删除管道文件。
- 在Linux系统中,可以使用C语言提供的access()函数来检查一个进程是否有访问某个文件或目录的权限。函数声明:
int access(const char *pathname, int mode);
- pathname参数指定要检查的文件或目录的路径,mode参数指定要检查的访问权限,常用的访问权限包括:
- F_OK:检查文件是否存在。
- R_OK:检查文件是否可读。
- W_OK:检查文件是否可写。
- X_OK:检查文件是否可执行。
- access()函数返回0表示检查成功,-1表示检查失败。如果access()函数返回-1,可以使用errno来获取错误码,通常的错误码包括:
- pathname参数指定要检查的文件或目录的路径,mode参数指定要检查的访问权限,常用的访问权限包括:
- 命名管道通信的实例:
- 管道读取端:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <string.h> int main(int argc, char *argv[]) { char buf[32] = {0}; int fd; if (argc < 2) { printf("Usage:%s <fifo name> \n", argv[0]); return -1; } fd = open(argv[1], O_RDONLY); while (1) { sleep(1); read(fd, buf, 32); printf("buf is %s\n", buf); memset(buf, 0, sizeof(buf)); } close(fd); return 0; }
- 管道写入端:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main(int argc, char *argv[]) { int ret; char buf[32] = {0}; int fd; if (argc < 2) { printf("Usage:%s <fifo name> \n", argv[0]); return -1; } if (access(argv[1], F_OK) == -1) { ret = mkfifo(argv[1], 0666); if (ret == -1) { printf("mkfifo is error \n"); return -2; } printf("mkfifo is ok \n"); } fd = open(argv[1], O_WRONLY); while (1) { sleep(1); write(fd, "hello", 5); } close(fd); return 0; }
-
编译命令并运行:
gcc fifo_write.c -o fifow gcc fifo_read.c -o fifor 打开两个终端分别运行 ./fifor fifo ./fifow fifo
消息队列
- 在 Linux 系统中,消息队列是一种进程间通信(IPC)机制,用于实现不同进程之间的数据传输。它是一种先进先出(FIFO)的数据结构,允许一个或多个进程通过在消息队列中发送和接收消息来通信。 消息队列由一个消息队列标识符(mqid)来标识,它类似于文件描述符,用于标识消息队列的唯一性。在创建消息队列时,需要指定一个唯一的键(key),这个键用于在系统范围内标识消息队列,确保多个进程可以通过相同的键访问同一个消息队列。消息队列支持在不同进程之间共享数据,这使得它成为一种非常强大的进程间通信机制。
-
使用消息队列进行进程间通信可以有以下优点:
- 可以实现多对多通信模式,多个进程可以同时读写同一个消息队列。
- 消息队列中的消息可以保持在系统中,即使发送消息的进程已经退出,接收进程仍然可以读取这些消息。
- 消息队列具有一定的容错能力,当接收进程暂时无法处理消息时,消息可以保留在队列中,等待接收进程重新准备好之后再进行处理。
- 消息队列可以通过指定优先级来处理消息,可以确保高优先级消息先被处理。
- 总之,消息队列是一种非常实用的进程间通信机制,在各种应用程序中广泛应用,例如网络通信、多线程编程、分布式系统等。
- 在Linux系统中,消息队列是一种进程间通信的机制,用于在不同的进程之间传递数据。在Linux系统中,消息队列的操作主要依赖于以下几个函数:
- 创建或获取一个消息队列函数msgget(),函数声明:
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> int msgget(key_t key, int msgflg);
- key参数是消息队列的键值,用于唯一地标识一个消息队列;
- msgflg参数是创建消息队列的标志位,用于指定消息队列的属性。
- 在 Linux 系统中,可以使用一些宏来生成用于 msgget() 函数的 key 参数的值。常用的宏有以下几种:
- IPC_PRIVATE 宏表示创建一个新的、私有的消息队列,仅当前进程可用。该标志一般用于在进程间共享数据时,仅仅在父子进程之间共享数据,不需要在不同进程之间共享。
- ftok() 函数可以根据给定的文件路径名和整数生成一个唯一的键值。在使用 ftok() 函数时,需要传递一个可访问的文件路径名和一个用户自定义的整数作为参数,例如:
key_t key = ftok("/tmp", 'a');
- 该代码将使用路径名 /tmp 和字符 'a' 生成一个用于 msgget() 函数的键值。
- IPC_CREAT 宏用于创建一个新的消息队列。如果指定的消息队列不存在,则创建一个新的消息队列,否则返回已有的消息队列的标识符。
- IPC_EXCL 宏用于指定如果同时指定了 IPC_CREAT 和 IPC_EXCL 标志,则只在消息队列不存在时创建一个新的消息队列,否则返回错误。
- msgflg参数用于指定消息队列的属性,可以使用多个标志位进行设置,其常用取值如下:
- IPC_CREAT:如果指定的消息队列不存在,则创建一个新的消息队列,否则返回已有的消息队列的标识符。
- IPC_EXCL:如果同时指定了 IPC_CREAT 和 IPC_EXCL 标志,则只在消息队列不存在时创建一个新的消息队列,否则返回错误。
- IPC_PRIVATE:表示创建一个新的、私有的消息队列,仅当前进程可用。该标志一般用于在进程间共享数据时,仅仅在父子进程之间共享数据,不需要在不同进程之间共享。
- 0666:表示创建的消息队列的权限,其取值与文件权限的表示方式相同。其中,6 表示读写权限,4 表示读权限,2 表示写权限,0 表示无权限。
- 向消息队列中发送消息函数msgsnd(),函数声明:
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
- msqid 参数是消息队列的标识符(由msgget函数得到);
- msgp 参数是指向消息缓冲区的指针,该缓冲区用来暂时存储要发送的消息,通常可用一个通用结构体来表示消息:
struct msgbuf { long mtype; /* 消息类型 */ char mtext[1024]; /* 消息内容 */ };
- msgsz 参数是发送消息的长度(字节数),使用公式sizeof(struct mymsg) - sizeof(long) 计算出消息的实际长度(不包括消息类型字段):
- 结构体 mymsg 的总大小为 sizeof(long) + 1024 = 1032 字节。
- msgsz = sizeof(struct mymsg) - sizeof(long) = 1032 - 8 = 1024字节。
- msgflg 参数用于指定发送消息的行为,可以取以下值:
- 0:表示阻塞方式,线程将被阻塞直到消息可以被写入。
- IPC_NOWAIT:表示非阻塞方式,如果消息队列已满或其他情况无法送入消息,函数立即返回。
- 如果函数执行成功就返回0,失败返回-1。
- 从消息队列中接收消息函数msgrcv(),函数声明:
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
- msqid 参数是消息队列的标识符;
- msgp 参数是指向接收消息的指针,通常可用一个通用结构体来表示消息:
struct msgbuf { long mtype; /* 消息类型 */ char mtext[1024]; /* 消息内容 */ };
- msgsz 参数是接收消息的长度;
- msgtyp 参数是指定接收消息的类型;
- msgflg 参数用于指定接收消息的行为,可以取以下值:
- 0:表示阻塞方式,当消息队列为空时,一直等待;
- IPC_NOWAIT:表示非阻塞方式,消息队列为空时,不等待,马上返回-1.如果函数执行成功,msgrcv返回到mtext数组的实际字节数。
- 控制消息队列的状态函数msgctl(),函数声明:
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> int msgctl(int msqid, int cmd, struct msqid_ds *buf);
- msqid 参数是消息队列的标识符;
- cmd 参数是指定执行的操作,包括删除消息队列、查询消息队列的状态等,可以取以下值:
- IPC_STAT:读取消息队列的属性,然后把它保存在buf指向的缓冲区;
- IPC_SET:设置消息队列的属性,这个值取自buf参数;
- IPC_EMID:将队列从系统内核中删除。
- buf 参数是指向 msqid_ds 结构体的指针,用于保存查询到的消息队列的状态信息。
- 下面是一个简单的消息队列示例程序,包括发送进程和接收进程:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/ipc.h> #include <sys/msg.h> #define MSG_KEY 1234 /* 定义消息结构体 */ struct mymsg { long mtype; /* 消息类型 */ char mtext[1024]; /* 消息内容 */ }; /* 发送进程 */ void sender() { int msgid, ret; struct mymsg msg; /* 创建或打开消息队列 */ msgid = msgget(MSG_KEY, IPC_CREAT | 0666); if (msgid < 0) { perror("msgget"); exit(1); } /* 构造消息 */ msg.mtype = 1; strncpy(msg.mtext, "Hello, waveshare!", 1024); /* 发送消息 */ ret = msgsnd(msgid, &msg, sizeof(struct mymsg) - sizeof(long), 0); if (ret < 0) { perror("msgsnd"); exit(1); } printf("Sent message: %s\n", msg.mtext); } /* 接收进程 */ void receiver() { int msgid, ret; struct mymsg msg; /* 打开消息队列 */ msgid = msgget(MSG_KEY, IPC_CREAT | 0666); if (msgid < 0) { perror("msgget"); exit(1); } /* 接收消息 */ ret = msgrcv(msgid, &msg, sizeof(struct mymsg) - sizeof(long), 1, 0); if (ret < 0) { perror("msgrcv"); exit(1); } printf("Received message: %s\n", msg.mtext); } int main() { pid_t pid; /* 创建子进程 */ pid = fork(); if (pid < 0) { perror("fork"); exit(1); } else if (pid == 0) { /* 子进程作为发送进程 */ sender(); } else { /* 父进程作为接收进程 */ receiver(); } return 0; }
system-V IPC 信号量
- System V 信号量是一种进程间同步和互斥的机制,它是由 AT&T 公司在 Unix System V 中开发的一种 IPC(Inter-Process Communication,进程间通信)机制。它的作用是通过对共享资源进行锁定和解锁来控制进程之间的互斥访问。具体来说,System V 信号量使用一个整数键值来标识一个信号量集合,集合中可以包含多个信号量。进程可以使用系统调用来获取信号量集合的标识符,并对其中的某个信号量执行不同类型的操作,如等待、释放、设置和查询信号量的值等。通过对信号量集合进行操作,进程可以实现互斥访问共享资源的目的。
- System V 信号量就是为了解决这种多进程间的同步和互斥问题而引入的机制。它使用了一种基于计数器的方法来控制对共享资源的访问,允许多个进程同时对资源进行访问,但同时限制了并发访问的数量。 这种机制能够确保每个进程都能够顺利地获取到资源的控制权,从而实现了多进程间的同步和互斥。
-
对信号量进行操作的两种基本方式:
- P 操作也称为等待(Wait)操作或者减小(Decrement)操作,它用于获取信号量的控制权并将信号量的值减一。具体来说,当进程需要访问某个共享资源时,它会调用 P 操作等待信号量,如果信号量的值大于等于 1,那么进程会减少信号量的值并继续执行,否则,进程将被阻塞等待,直到信号量的值变为大于等于 1。
- V 操作也称为释放(Signal)操作或者增加(Increment)操作,它用于释放信号量的控制权并将信号量的值加一。具体来说,当进程完成对某个共享资源的访问后,它会调用 V 操作释放信号量,这会使得被阻塞等待该信号量的进程重新获得控制权并继续执行。
-
信号量集的创建与打开:
#include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h> int semget(key_t key, int nsems, int semflg);
- 参数含义:
- 参数 key 表示所创建或打开的信号量集的键;
- 参数 nsems 表示创建的信号量集中信号量的个数,此参数只在创建一个新的信号量集时有效;
- 参数 semflg 表示调用函数的操作类型,也可用于设置信号量集的访问权限。所以调用函数 semget 的作用由参数 key 和 semflg 决定。
- 返回值:当函数调用成功时,返回值为信号量的引用标识符,调用失败时,返回值为 -1。
- 参数含义:
-
对信号量集的操作:
#include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h> int semop(int semid, struct sembuf *sops, size_t nsops);
- 参数含义:
- semid 为信号量集的引用标识符;
- sops 为指向 sembuf 类型的数组的指针,sembuf 结构用于指定调用 semop 函数所做的操作。
- 参数含义:
共享内存
多线程编程
多线程编程的基本概念
- 线程是计算机中实现多任务的基本单位之一,它是进程中的一个执行流程。一个进程可以包含多个线程,每个线程可以独立运行,并且共享该进程的内存空间和系统资源。
- 线程的特点是轻量级,创建和销毁的开销比进程小,同时多个线程之间的切换也比进程的切换快速,因此在需要同时执行多个任务的情况下,使用线程可以提高程序的性能和响应速度。
- 线程通常由操作系统调度执行,并可以通过同步机制来控制多个线程之间的协作和互斥访问共享资源。常见的同步机制包括互斥锁、条件变量、信号量等。线程的实现方式包括用户级线程和内核级线程,其中用户级线程是由应用程序自己实现的,而内核级线程则由操作系统实现。
使用多线程的好处
- 提高程序的并发性和响应速度:多线程可以使程序中的多个任务并行执行,从而提高程序的并发性和响应速度。例如,在一个网络服务器中,可以使用多线程来处理客户端请求,从而提高服务器的处理能力和响应速度。
- 更小的系统开销:线程在同一进程内部运行,因此相对于多进程来说,线程间的切换和通信的开销更小。多进程需要使用进程间通信(IPC)机制进行进程间数据的传递和同步,而线程可以直接访问共享内存,避免了IPC的开销。
- 充分利用多核CPU的性能:多线程可以充分利用多核CPU的性能,使多个线程在不同的CPU核心上并行执行,从而提高程序的性能。例如,在图像处理、视频编码等密集计算场景中,可以使用多线程来加速计算。
- 提高资源利用率:多线程可以共享同一进程的代码段、数据段和堆空间,从而减少资源的浪费。例如,在一个文件压缩程序中,可以使用多线程来并行处理多个文件,从而提高磁盘和CPU的利用率。
- 简化程序设计:多线程可以将复杂的任务分解成多个简单的子任务,每个子任务可以由一个单独的线程来处理,从而简化程序的设计和实现。例如,在一个图形界面程序中,可以使用多线程来分离界面和后台任务的处理,使程序更加清晰和易于维护。 需要注意的是,多线程也存在一些问题,例如线程安全、资源竞争、死锁等,需要仔细考虑和解决。因此,在使用多线程时需要谨慎设计和同步机制,以确保线程之间的正确协作。
-
虽然多线程给应用开发带来了好处,但是并不是所有情况都适合多线程,使用多线程的情况包括但不限于以下几种:
- 并发执行多个任务。
- 处理耗时任务。
- 各个任务有不同的优先级。
- 实现异步操作。
线程的状态
- 一个线程从创建到结束是一个生命周期,总是处于以下几种状态:
- 就绪态:当线程可以开始运行,但还没有被调度执行时,线程处于就绪状态。此时,线程已经分配了所有需要的系统资源,等待操作系统的调度器分配CPU资源。
- 运行状态:当线程被调度执行并开始运行时,线程处于运行状态。
- 阻塞状态:当线程需要等待某些事件发生时,如等待I/O操作完成、等待信号量、等待锁等,线程就会进入阻塞状态。此时,线程不会占用CPU资源。
- 终止状态:当线程执行完成或发生了未处理的异常时,线程就会进入终止状态。此时,线程所占用的系统资源会被释放,线程对象被销毁。
线程标识
- 在多线程编程中,线程的标识ID号是用来唯一标识一个线程的。
- 线程标识通常是一个整数值,由操作系统内核自动生成,并由编程语言提供的库函数返回。
- 线程标识的ID号从线程创建开始存在,线程结束后,该ID号就自动消失。
创建线程
- 在Linux系统下的多线程遵循POSIX标准,那么在开发之前先来熟悉一下POSIX多线程API函数:
API函数 含义 pthread_create 创建一个新线程 pthread_join 等待一个线程结束并获取其返回值 pthread_self 获取线程ID pthread_cancel 取消另一个线程 pthread_exit 在线程函数中调用来退出线程函数 pthread_kill 向线程发送一个信号
pthread_create函数
- 在POSIX API中使用pthread_create()函数创建进程,函数声明:
#include <pthread.h> int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
-
参数说明:
- thread:指向线程标识符的指针。
- attr:设置线程属性,具体内容在下一小节讲解。
- start_routine:start_routine是一个函数指针,指向要运行的线程入口,即线程运行时要执行的函数代码。
- arg:运行线程时传入的参数。
- 返回值:若线程创建成功,则返回0。若线程创建失败,则返回对应的错误代码。
- 除此之外在链接时需要使用库libpthread.a。因为pthread的库不是Linux系统的库, 所以在编译时要加上-lpthread 选项。
pthread_join函数
- 在POSIX API中使用pthread_join()函数等待一个指定的线程结束并获取它的退出状态,函数声明:
#include <pthread.h> int pthread_join(pthread_t thread, void **retval);
- thread 参数是被等待的线程的标识符;
- retval 参数是一个指向指针的指针,用于存储被等待的线程的退出状态。
- 如果不需要获取被等待线程的退出状态,可以将 retval 参数设置为 NULL。
pthread_exit函数
- 在POSIX API中使用pthread_exit()函数显式地终止当前线程的执行并退出线程,函数声明:
#include <pthread.h> void pthread_exit(void *retval);
- retval 参数是一个指向任意类型数据的指针;
- 表示线程的退出状态。
- 如果不需要获取线程的退出状态,可以将 retval 参数设置为 NULL。
- 在线程执行过程中遇到了 return,也会终止执行;既然 return 关键字也适用于线程函数,<pthread.h> 头文件为什么还提供 pthread_exit() 函数,它们的区别如下:
- return (void*)0; 和 pthread_exit((void*)0) 的主要区别在于:return 语句只是从线程函数中返回并退出当前线程,而 pthread_exit() 函数可以在任何时候使用并显式地退出线程。
- pthread_exit() 函数还提供了线程清理处理的功能,可以在退出线程时调用所有已注册的线程清理处理函数,从而更好地管理线程资源。
创建一个线程,并传入整型参数
- 打开编辑器,新建一个thread.c文件,在thread.c输入代码:
#include <stdio.h> #include <pthread.h> #include <unistd.h> void *thread_func(void *arg) { int *count = (int*)arg; for (int i = 1; i <= *count; ++i) { printf("Thread: %d\n", i); sleep(1); } pthread_exit(NULL); } int main(int argc, char *argv[]) { pthread_t tid; int ret; int count = 5; ret = pthread_create(&tid, NULL, thread_func, &count); if (ret != 0) { printf("pthread_create failed:%d\n", ret); return -1; } printf("create treads success\n"); ret = pthread_join(tid, NULL); if ( ret != 0) { printf("pthread_join"); return -1; } printf("Thread finished.\n"); return 0; }
- 编译和运行程序:
gcc -o thread thread.c -lpthread ./thread
线程属性
- POSIX标准规定线程有多个属性,其中包括:分离状态、调度策略和参数、作用域、栈尺寸、栈地址、优先级等。
分离状态
- 在 POSIX 线程库中,一个线程可以是分离的,也可以是非分离的。线程的分离状态定义了线程的结束方式以及线程占用的系统资源。
- 如果一个线程是分离的,那么它结束时不会留下任何资源,包括线程 ID 和线程占用的内存资源。这意味着它不能被其他线程 join,也不能被获取状态信息。
- 可以通过 pthread_attr_setdetachstate() 函数来设置线程的分离状态,其参数可以是 PTHREAD_CREATE_JOINABLE 或者 PTHREAD_CREATE_DETACHED,分别对应着非分离和分离状态。
- 一般来说,如果一个线程只是执行一些独立的任务,不需要其他线程 join 获取其状态信息,那么可以将其设置为分离状态,以减少资源占用。 但是需要注意的是,对于分离状态的线程,不能通过 join 等方式来确保线程已经结束,需要自己在代码中处理好线程的结束。
- 通过线程属性设置线程的分离状态:
- 初始化线程属性:
int pthread_attr_init(pthread_attr_t *attr);
- 销毁线程属性所占用的资源:
int pthread_attr_destroy(pthread_attr_t *attr);
- 返回值:成功:0;失败:错误号。
- 设置线程属性,分离 or 非分离:
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
- attr:已初始化的线程属性(传入参数)detachstate: PTHREAD_CREATE_DETACHED(分离线程)、PTHREAD _CREATE_JOINABLE(非分离线程)
- 初始化线程属性:
- 创建一个子线程,设置了其分离状态为PTHREAD_CREATE_DETACHED,使得该线程在结束时自动释放资源:
#include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <unistd.h> void *thread_func(void *arg) { printf("Child thread is running.\n"); pthread_exit(NULL); } int main() { pthread_t tid; pthread_attr_t attr; int detachstate; // 初始化线程属性对象 pthread_attr_init(&attr); // 获取默认的分离状态 pthread_attr_getdetachstate(&attr, &detachstate); printf("Default detach state: %s\n", detachstate == PTHREAD_CREATE_JOINABLE ? "joinable" : "detached"); // 设置线程的分离状态为detached pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_create(&tid, &attr, thread_func, NULL); // 销毁线程属性对象 pthread_attr_destroy(&attr); // 主线程等待一会,以保证子线程已经运行 sleep(1); printf("Main thread is exiting.\n"); pthread_exit(NULL); }
栈尺寸
- 栈(stack)是一种数据结构,用于存储函数调用、局部变量以及临时数据等信息。每个线程都有自己的栈,用于存储其执行期间所需要的信息。
- 栈的尺寸是指在创建线程时分配给它的栈空间大小。栈的大小直接影响线程能够处理的数据量和执行时间。如果栈太小,则可能会导致栈溢出,程序崩溃。如果栈太大,则会浪费系统资源。
- 在创建线程时,可以通过指定线程属性来设置线程的栈尺寸。例如,在 POSIX 线程库中,可以使用 pthread_attr_setstacksize() 函数设置线程栈的大小,单位是字节。
- 栈尺寸的大小应该根据具体应用程序的需要进行调整。如果应用程序需要处理大量数据或者需要递归调用函数,那么可能需要分配更大的栈空间。如果应用程序只需要处理少量数据,那么可以使用较小的栈空间,以节省系统资源。
- 在 POSIX 线程库中,可以使用函数设置和获取线程栈的大小,单位是字节,函数声明:
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize); int pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize);
- 参数说明:
- attr:指向一个线程属性的指针。
- stacksize:线程栈的大小。
调度策略
- 线程的调度策略指的是操作系统如何安排线程在 CPU 上的执行顺序。不同的调度策略会影响线程的优先级和执行方式。
- POSIX 线程库定义了三种调度策略:
- SCHED_FIFO(先进先出):按照线程加入队列的顺序执行,直到该线程运行结束或者它被抢占,才会执行下一个线程。优先级高的线程会先执行,优先级相同的按照先来先服务的顺序执行。
- SCHED_RR(轮流调度):每个线程执行的时间片固定,当时间片用完后,将被放回队列尾部,等待下一次调度。同样,优先级高的线程会先执行,优先级相同的按照先来先服务的顺序执行。
- SCHED_OTHER(其他):由系统决定线程的调度,该策略常常被用作多线程编程的默认策略,通常不需要显式地指定。线程的调度策略可以使用 pthread_attr_setschedpolicy() 函数设置,也可以使用 pthread_setschedparam() 函数动态地修改线程的优先级和调度策略。
- 在 POSIX 线程库中,可以使用函数获取和设置线程的调度策略与参数,函数声明:
int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched); int pthread_attr_getinheritsched(const pthread_attr_t *attr, int *inheritsched); int pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy); int pthread_attr_getschedpolicy(const pthread_attr_t *attr, int *policy);
-
参数说明:
- attr:指向一个线程属性的指针。
- inheritsched:线程是否继承调度属性,可选值分别为:
- PTHREAD_INHERIT_SCHED:调度属性将继承于创建的线程,attr中设置的调度属性将被忽略。
- PTHREAD_EXPLICIT_SCHED:调度属性将被设置为attr中指定的属性值。
- policy:可选值为线程的三种调度策略,SCHED_OTHER、SCHED_FIFO、SCHED_RR。
- 返回值:若函数调用成功返回0,否则返回对应的错误代码。
优先级
-
在Linux系统中,线程的优先级分为静态优先级(static priority)和动态优先级(dynamic priority)两种。
- 静态优先级是在创建线程时分配的,通常使用pthread_attr_setschedparam()函数来设置。静态优先级值越小,表示线程优先级越高,反之越低。当线程的调度策略为SCHED_OTHER时,其静态优先级必须设置为0。这是Linux系统调度的默认策略,处于0优先级别的这些线程按照动态优先级被调度。在实时调度策略中,静态优先级决定了实时线程的基本调度次序。
- 动态优先级则是在运行时根据线程的行为和资源需求而动态调整的。在Linux系统中,动态优先级是使用nice值来计算的。nice值是一个整数,取值范围为-20到+19,其中-20表示最高优先级,+19表示最低优先级。当一个线程处于就绪状态但是无法被调度时,其动态优先级会增加一个单位,这样能够保证这些线程之间的竞争公平性。而当线程被调度后,其动态优先级会降低一定的值,以防止某个线程占用CPU时间过长,导致其他线程无法得到执行。
- 设置线程的最小和最大优先级,函数声明:
int sched_get_priority_max(int policy); int sched_get_priority_min(int policy);
- 获取线程3种调度策略下可设置的最小和最大优先级:
#include <stdio.h> #include <unistd.h> #include <sched.h> int main() { printf("Valid priority range for SCHED_OTHER: %d - %d\n", sched_get_priority_min(SCHED_OTHER), sched_get_priority_max(SCHED_OTHER)); printf("Valid priority range for SCHED_FIFO: %d - %d\n", sched_get_priority_min(SCHED_FIFO), sched_get_priority_max(SCHED_FIFO)); printf("Valid priority range for SCHED_RR: %d - %d\n", sched_get_priority_min(SCHED_RR), sched_get_priority_max(SCHED_RR)); return 0; }
-
SCHED_OTHER是不支持优先级使用的,而SCHED_FIFO和SCHED_RR支持优先级的使用,他们分别为1和99,数值越大优先级越高。设置和获取优先级通过以下两个函数:
int pthread_attr_setschedparam(pthread_attr_t *attr, const struct sched_param *param); int pthread_attr_getschedparam(const pthread_attr_t *attr, struct sched_param *param);
-
设置和获取线程的调度参数,包括调度策略和优先级:
#include <pthread.h> #include <sched.h> #include <stdio.h> int main() { pthread_attr_t attr; struct sched_param param; int policy; // 初始化线程属性对象 pthread_attr_init(&attr); // 获取默认调度策略 pthread_attr_getschedpolicy(&attr, &policy); // 打印默认调度策略 if (policy == SCHED_OTHER) { printf("Default scheduling policy is SCHED_OTHER\n"); } else if (policy == SCHED_RR) { printf("Default scheduling policy is SCHED_RR\n"); } else if (policy == SCHED_FIFO) { printf("Default scheduling policy is SCHED_FIFO\n"); } // 获取默认的调度参数 pthread_attr_getschedparam(&attr, ¶m); // 打印默认的优先级 printf("Default priority is %d\n", param.sched_priority); // 设置线程的调度策略和优先级 param.sched_priority = 50; pthread_attr_setschedpolicy(&attr, SCHED_FIFO); pthread_attr_setschedparam(&attr, ¶m); // 获取新的调度策略和优先级 pthread_attr_getschedpolicy(&attr, &policy); pthread_attr_getschedparam(&attr, ¶m); // 打印新的调度策略和优先级 if (policy == SCHED_OTHER) { printf("New scheduling policy is SCHED_OTHER\n"); } else if (policy == SCHED_RR) { printf("New scheduling policy is SCHED_RR\n"); } else if (policy == SCHED_FIFO) { printf("New scheduling policy is SCHED_FIFO\n"); } printf("New priority is %d\n", param.sched_priority); // 销毁线程属性对象 pthread_attr_destroy(&attr); return 0; }
线程的结束
- 线程的结束指的是线程执行完它的任务后退出,释放它占用的资源,以便其他线程能够使用这些资源。线程的结束可以有两种方式:
- 正常结束:线程执行完它的任务后,通过调用pthread_exit()函数或者从线程函数中返回来退出线程。在正常结束的情况下,线程会自动释放它所占用的资源,包括栈空间、线程描述符和其他资源等。
- 异常结束:线程在执行的过程中出现了错误,导致线程无法继续执行,此时线程会自动退出。在这种情况下,需要确保线程释放它所占用的所有资源,以避免资源泄漏和内存泄漏等问题。
- 总之,线程的结束是非常重要的,它可以避免资源泄漏和内存泄漏等问题,同时也可以提高系统的性能和稳定性。因此,在编写多线程程序时,要注意线程的结束,保证线程能够正常退出,释放所有占用的资源。
- 在线程的创建线程部分,我们介绍过线程执行过程中遇到了 pthread_exit() 或者 return,会终止执行;
pthread_cancel函数
- 在多线程程序中,一个线程还可以向另一个线程发送“终止执行”的信号(后续称“Cancel”信号),这时就需要调用 pthread_cancel() 函数,函数声明:
#include <pthread.h> int pthread_cancel(pthread_t thread);
- 在使用pthread_cancel() 函数之前,要了解目标线程对cancle信号的处理机制。
- 对于默认属性的线程,当向目标线程发送取消请求( Cancel 信号)时,它并不会立即结束执行,而是遇到取消点(Cancellation points)时,会响应 Cancel 信号并终止执行。
- POSIX标准中将允许取消操作的函数称为“cancellation points”,也称为“cancellable functions”。
- 取消点是指在程序执行期间,允许取消一个线程并执行清理工作的特定代码位置。在POSIX标准中,许多标准的系统调用和库函数都是取消点,例如I/O函数、内存分配函数等等。比如常见的 pthread_join()、pthread_testcancel()、sleep()、system() 等。
pthread_setcancelstate
- 如果想要手动修改目标线程处理 Cancel 信号的方式,我们可以使用 pthread_setcancelstate() 和 pthread_setcanceltype() 这两个函数。
- pthread_setcancelstate()函数声明:
#include <pthread.h> int pthread_setcancelstate( int state , int * oldstate );
- 参数含义:
- state 参数有两个可选值:
- PTHREAD_CANCEL_ENABLE(默认值):当前线程会处理其它线程发送的 Cancel 信号;
- PTHREAD_CANCEL_DISABLE:当前线程不理会其它线程发送的 Cancel 信号,直到线程状态重新调整为 PTHREAD_CANCEL_ENABLE 后,才处理接收到的 Cancel 信号。
- oldtate 参数用于接收线程先前所遵循的 state 值,通常用于对线程进行重置。如果不需要接收此参数的值,置为 NULL 即可。
- state 参数有两个可选值:
- 返回值:pthread_setcancelstate() 函数执行成功时,返回数字 0,反之返回非零数。
pthread_setcanceltype函数
- pthread_setcanceltype()函数声明:
#include <pthread.h> int pthread_setcanceltype( int type , int * oldtype );
- 参数含义:
- type 参数有两个可选值,分别是:
- PTHREAD_CANCEL_DEFERRED(默认值):当线程执行到某个可作为取消点的函数时终止执行;
- PTHREAD_CANCEL_ASYNCHRONOUS:线程接收到 Cancel 信号后立即结束执行。
- oldtype 参数用于接收线程先前所遵循的 type 值,如果不需要接收该值,置为 NULL 即可。
- type 参数有两个可选值,分别是:
- 返回值:pthread_setcanceltype() 函数执行成功时,返回数字 0,反之返回非零数。
- 创建一个线程tid,分别将线程的取消状态和取消类型设置为PTHREAD_CANCEL_DISABLE和PTHREAD_CANCEL_ASYNCHRONOUS,主线程sleep(2)后向线程发送了一个取消请求,线程即取消。
#include <pthread.h> #include <stdio.h> void* thread_func(void* arg) { int oldstate, oldtype; pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &oldstate); pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, &oldtype); printf("Thread started.\n"); while (1) { printf("Thread running...\n"); } printf("Thread ended.\n"); pthread_exit(NULL); } int main() { pthread_t tid; pthread_create(&tid, NULL, thread_func, NULL); sleep(2); pthread_cancel(tid); printf("Thread cancelled.\n"); pthread_join(tid, NULL); printf("Main thread ended.\n"); return 0; }
多线程高级编程
多线程的同步和异步
- 并行是指两个或多个任务同时执行,实现方式可以是在多个处理器上同时运行或一个处理器上交替执行。简单来说,就是做多个事情。
- 并发是指在一段时间内宏观上有多个程序在同时运行,但是在同一时刻只有一个程序在处理器上运行。简单来说,就是多个程序互相抢占CPU资源,让CPU快速切换,看起来好像是同时执行。
- 多线程的同步和异步是指多个线程之间的协调和合作方式。 在同步模式下,多个线程之间需要相互协作,一个线程的操作需要等待另一个线程的操作完成才能继续执行,在同步模式下,通常会使用锁、条件变量、信号量等机制来实现线程之间的协调和同步,以保证各个线程之间的操作顺序和正确性。 而异步模式下,则多个线程可以独立运行,彼此之间不需要进行协调,一个线程的操作不会受到其他线程的影响。
- 例如,在多线程程序中,多个线程可能同时访问共享数据,此时需要使用锁机制来保证数据的一致性和完整性,避免数据竞争和错误。 又如,在生产者和消费者模式中,生产者线程和消费者线程需要彼此协作,以保证生产和消费的顺序和数量匹配,此时可以使用条件变量和信号量来实现线程之间的协调和同步。 在异步模式下,各个线程可以独立运行,彼此之间不需要进行协调和同步,可以根据需要随时启动、暂停、终止线程,以实现更加灵活的程序控制。 例如,在多线程网络编程中,可以为每个客户端连接启动一个独立的线程来处理数据交换,各个线程之间独立运行,彼此不会影响,可以有效提高程序的并发性和响应速度。
利用POSIX多线程API函数进行线程的同步
- 对于多线程程序来说,同步是指在一定的时间内只允许某一个线程来访问某个资源。而在此时间内,不允许其他的线程访问该资源。可以通过互斥锁(Mutex)、条件变量(condition variable)、读写锁(reader-writer lock)、信号量(semaphore)来同步资源。
互斥锁
- 互斥锁是线程同步的一种机制,用于保护共享资源免受并发访问的影响。互斥锁只允许一个线程访问共享资源,其他线程必须等待互斥锁被释放后才能访问。锁的获取和释放也称上锁(lock)和解锁(unlock)。
- 在 POSIX 系统中,互斥锁是通过 pthread 库实现的。下面介绍几个常用的互斥锁函数:
- 用于初始化互斥锁的函数是pthread_mutex_init(),函数声明:
#include <pthread.h> int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
- 参数说明:
- mutex:指向互斥锁的指针;
- attr:指向互斥锁属性的指针,可以为 NULL。
- 返回值说明:成功时返回0,失败时返回错误码。
- 参数说明:
- 线程初始化后,就可以上锁了,用于上锁的函数是pthread_mutex_lock(),函数声明:
#include <pthread.h> int pthread_mutex_lock(pthread_mutex_t *mutex);
- 参数说明:
- mutex:指向互斥锁的指针。
- 返回值说明:成功时返回0,失败时返回错误码。
- 参数说明:
- 加锁和解锁操作必须成对出现,否则会导致死锁或其他问题,用于解锁的函数是pthread_mutex_unlock(),函数声明:
#include <pthread.h> int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 参数说明:
- mutex:指向互斥锁的指针。
- 返回值说明:成功时返回0,失败时返回错误码。
- 参数说明:
- 当互斥锁用完后,最终要销毁,用于销毁互斥锁的函数是pthread_mutex_destroy(),函数声明:
#include <pthread.h> int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 参数说明:
- mutex:指向互斥锁的指针。
- 返回值说明:成功时返回0,失败时返回错误码。
- 参数说明:
- 使用互斥锁前多线程累加示例:
#include <stdio.h> #include <unistd.h> #include <pthread.h> #include <sys/time.h> #include <string.h> #include <stdlib.h> int n = 0; pthread_mutex_t mutex; void *thread_1(void *arg) { int j; for (j = 0; j < 1000000; j++) { n++; } pthread_exit((void *)0); } void *thread_2(void *arg) { int j; for (j = 0; j < 1000000; j++) { n++; } pthread_exit((void *)0); } int main(void) { int j,err; pthread_t th1, th2; for (j = 0; j < 10; j++) { err = pthread_create(&th1, NULL, thread_1, (void *)0); if (err != 0) { printf("create new thread error:%s\n", strerror(err)); exit(0); } err = pthread_create(&th2, NULL, thread_2, (void *)0); if (err != 0) { printf("create new thread error:%s\n", strerror(err)); exit(0); } err = pthread_join(th1, NULL); if (err != 0) { printf("wait thread done error:%s\n", strerror(err)); exit(1); } err = pthread_join(th2, NULL); if (err != 0) { printf("wait thread done error:%s\n", strerror(err)); exit(1); } printf("n=%d\n", n); n = 0; } return 0; }
- 使用互斥锁后多线程累加示例:
#include <stdio.h> #include <unistd.h> #include <pthread.h> #include <sys/time.h> #include <string.h> #include <stdlib.h> int n = 0; pthread_mutex_t mutex; void *thread_1(void *arg) { int j; for (j = 0; j < 1000000; j++) { pthread_mutex_lock(&mutex); n++; pthread_mutex_unlock(&mutex); } pthread_exit((void *)0); } void *thread_2(void *arg) { int j; for (j = 0; j < 1000000; j++) { pthread_mutex_lock(&mutex); n++; pthread_mutex_unlock(&mutex); //解锁 } pthread_exit((void *)0); } int main(void) { int j,err; pthread_t th1, th2; pthread_mutex_init(&mutex, NULL); //初始化互斥锁 for (j = 0; j < 10; j++) { err = pthread_create(&th1, NULL, thread_1, (void *)0); if (err != 0) { printf("create new thread error:%s\n", strerror(err)); exit(0); } err = pthread_create(&th2, NULL, thread_2, (void *)0); if (err != 0) { printf("create new thread error:%s\n", strerror(err)); exit(0); } err = pthread_join(th1, NULL); if (err != 0) { printf("wait thread done error:%s\n", strerror(err)); exit(1); } err = pthread_join(th2, NULL); if (err != 0) { printf("wait thread done error:%s\n", strerror(err)); exit(1); } printf("n=%d\n", n); n = 0; } pthread_mutex_destroy(&mutex); //销毁互斥锁 return 0; }
- 上述两个程序的含义都是创建两个线程,都是将n累加1000000次,最终的输出结果应为2000000,实际上由于线程间产生了资源竞争,未加互斥锁的程序的输出结果无法累加到2000000。
读写锁
- 读写锁是一种特殊的锁,可以提高并发程序中对于共享资源的读操作效率。读写锁分为读锁和写锁两种,读锁可被多个线程同时持有,但是写锁只能被单个线程持有。当某个线程占用写锁时,其他线程无法持有读锁或写锁,这样可以保证数据的一致性。读写锁适用于多读少写的场景,因为它可以允许多个线程同时读取共享资源,而不会因为读操作的阻塞而影响程序性能。
- 在 POSIX 线程库中,初始化读写锁使用的函数是pthread_rwlock_init(),函数声明:
#include <pthread.h> int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr)
- 参数含义:
- rwlock:指向 pthread_rwlock_t 结构体的指针,即需要被初始化的读写锁;
- attr:指向 pthread_rwlockattr_t 结构体的指针,表示读写锁的属性。如果该参数为 NULL,则使用默认属性。
- 返回值:如果函数执行成功就返回0,否则返回错误码。
- 参数含义:
- 读写锁的上锁可分为读模式下的上锁和写模式下的上锁,函数声明:
#include <pthread.h> pthread_rwlock_rdlock(pthread_rwlock_t *rwlock)
- 参数含义:
- rwlock:指向 pthread_rwlock_t 结构体的指针,即需要被加锁的读写锁。
- 返回值:如果获取读锁成功,函数返回值为 0,否则返回错误码。
#include <pthread.h> pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock)
- 参数含义:
- rwlock:指向 pthread_rwlock_t 结构体的指针,即需要被加锁的读写锁。
- 返回值:如果获取读锁成功,函数返回值为 0,否则返回错误码。
- 使用 pthread_rwlock_tryrdlock() 函数尝试获取读锁时,如果有其他线程已经获取了写锁(互斥锁),则当前线程尝试获取读锁会失败,函数将立即返回失败。
#include <pthread.h> pthread_rwlock_wrlock(pthread_rwlock_t *rwlock)
- 参数含义:
- rwlock:指向 pthread_rwlock_t 结构体的指针,即需要被加锁的读写锁。
- 函数执行成功后,当前线程将持有写锁(独占锁),其他线程将无法同时持有读锁或写锁,直到当前线程释放写锁。
- 返回值:如果获取写锁成功,函数返回值为 0,否则返回错误码。
#include <pthread.h> pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock)
- 参数含义:
- rwlock:指向 pthread_rwlock_t 结构体的指针,即需要被加锁的读写锁。
- 函数执行成功后,当前线程将持有写锁(独占锁),其他线程将无法同时持有读锁或写锁,直到当前线程释放写锁。
- pthread_rwlock_trywrlock() 函数用于尝试获取读写锁的写锁(独占锁),如果当前读写锁已经被加了读锁或写锁,则函数将立即返回失败。其参数含义如下:
- 返回值:如果获取写锁成功,函数返回值为 0,否则返回错误码。
- 参数含义:
- 当线程退出临界区后,要对读写锁进行解锁,解锁的函数是pthread_rwlock_unlock(),函数声明:
#include <pthread.h> pthread_rwlock_unlock(pthread_rwlock_t *rwlock)
- 参数含义:
- rwlock:指向 pthread_rwlock_t 结构体的指针,即需要被解锁的读写锁。
- 函数执行成功后,当前线程将释放读锁或写锁,其他线程将可以持有读锁或写锁。
- 参数含义:
- 当读写锁用完后,最终需要销毁,用于销毁读写锁的函数是pthread_rwlock_destroy(),函数声明:
#include <pthread.h> pthread_rwlock_destroy(pthread_rwlock_t *rwlock)
- 参数含义:
- rwlock:指向 pthread_rwlock_t 结构体的指针,即需要被销毁的读写锁。
- 函数执行成功后,读写锁将被销毁,释放相关的资源。
- 参数含义:
- 模拟一个共享变量的读写过程:
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #define NUM_THREADS 3 #define NUM_ITERATIONS 10 int shared_data = 0; // 共享数据 pthread_rwlock_t rwlock; // 读写锁 void *reader(void *arg) { for (int i = 0; i < NUM_ITERATIONS; ++i) { pthread_rwlock_rdlock(&rwlock); printf("Reader %ld: read shared_data = %d\n", (long int) arg, shared_data); pthread_rwlock_unlock(&rwlock); usleep(rand() % 100000); // 模拟读操作耗时 } pthread_exit(NULL); } void *writer(void *arg) { for (int i = 0; i < NUM_ITERATIONS; ++i) { pthread_rwlock_wrlock(&rwlock); shared_data++; printf("Writer %ld: write shared_data = %d\n", (long int) arg, shared_data); pthread_rwlock_unlock(&rwlock); usleep(rand() % 100000); // 模拟写操作耗时 } pthread_exit(NULL); } int main() { pthread_t threads[NUM_THREADS]; pthread_rwlock_init(&rwlock, NULL); // 创建读线程和写线程 for (intptr_t i = 0; i < NUM_THREADS; ++i) { if (i == 0) { pthread_create(&threads[i], NULL, writer, (void *) i); } else { pthread_create(&threads[i], NULL, reader, (void *) i); } } // 等待线程结束 for (int i = 0; i < NUM_THREADS; ++i) { pthread_join(threads[i], NULL); } pthread_rwlock_destroy(&rwlock); return 0; }
条件变量
- 条件变量(Condition Variable)是一种用于线程同步的机制。它允许线程等待某个条件的发生,当条件不满足时,线程会被阻塞,直到另一个线程通知该条件发生的变化, 从而使得阻塞的线程可以继续执行。条件变量通常和互斥锁(Mutex)一起使用。当一个线程需要等待某个条件的发生时,它会先获取互斥锁,然后判断条件是否满足, 如果不满足,则通过条件变量进入等待状态,等待其他线程通知该条件的发生变化。当条件发生变化时,通知等待的线程即可。
- 条件变量由两个主要操作组成:wait 和 signal。wait 操作用于等待信号,如果条件不满足则线程会在条件变量上等待。signal 操作用于发出信号,通知等待线程条件已经满足,可以继续执行了。 在使用条件变量时,通常需要与互斥锁配合使用。当等待线程收到信号时,它会重新获得互斥锁,并检查条件是否满足,然后继续执行。因此,条件变量的使用通常包含以下几个步骤:
- 初始化条件变量和互斥锁
- 在等待线程中,获取互斥锁,检查条件是否满足,如果不满足,则使用条件变量等待信号
- 在发出信号的线程中,获取互斥锁,更改条件状态,然后使用条件变量发出信号
- 在等待线程中,收到信号后,重新获取互斥锁,检查条件是否满足,如果满足,则继续执行
- 需要注意的是,在使用条件变量时,必须先获得互斥锁,再使用条件变量等待或发出信号,否则会导致竞态条件的发生。同时,使用条件变量时还需要处理虚假唤醒的情况,即线程在等待条件变量时可能会被意外唤醒,因此等待线程需要再次检查条件是否满足。
- 条件变量可以通过静态初始化和函数初始化两种方式来创建和初始化。
- 静态初始化方式适用于条件变量在定义时即进行初始化的情况,使用 PTHREAD_COND_INITIALIZER 宏来初始化一个条件变量,该宏会生成一个 pthread_cond_t 类型的静态变量并初始化为默认值,代码如下:
#include <pthread.h> pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
- 函数初始化方式适用于在运行时动态创建条件变量的情况,需要使用 pthread_cond_init 函数进行初始化,该函数的原型为:
#include <pthread.h> int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
- cond 参数是指向条件变量对象的指针,attr 参数是指向条件变量属性的指针,通常设置为 NULL。
- 等待条件变量。在使用条件变量时,通常需要等待某个条件满足才能继续执行,这时可以使用pthread_cond_wait()函数等待条件变量的通知。函数声明:
#include <pthread.h> pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
该函数将会阻塞当前线程,直到有其他线程调用pthread_cond_signal或pthread_cond_broadcast通知条件变量。 在等待前,需要获取mutex锁,并在等待过程中将mutex锁释放,以便其他线程可以修改共享状态。 当等待结束后,该函数会自动获取mutex锁并返回。此时可以在获取mutex锁后,检查条件是否满足并继续执行。 需要注意的是,pthread_cond_wait函数可能会发生虚假唤醒,即使条件变量没有被通知也会返回。
因此,在等待条件变量时,通常需要将等待过程放在一个循环中,并检查条件是否满足,以避免虚假唤醒导致的错误。 另外,使用pthread_cond_wait函数前,还需要确保mutex锁已经被获取,否则该函数将会产生未定义的行为。因此,一般建议使用如下的方式等待条件变量:
pthread_mutex_lock(&mutex); while (!condition_is_satisfied()) { pthread_cond_wait(&cond, &mutex); } // 这里条件满足,可以继续执行 pthread_mutex_unlock(&mutex);
- 唤醒等待条件变量的线程。可以使用 pthread_cond_signal() 或 pthread_cond_broadcast() 函数来唤醒等待条件变量的线程,函数声明:
#include <pthread.h> int pthread_cond_signal(pthread_cond_t *cond); int pthread_cond_broadcast(pthread_cond_t *cond);
- 参数含义: cond 是指向条件变量对象的指针。
- 返回值:成功返回 0,失败返回一个正整数的错误代码。
- 注意:这两个函数都需要先获得相应的互斥锁,以避免发生竞争条件。pthread_cond_signal 函数会唤醒等待在条件变量上的某一个线程(如果有的话),而 pthread_cond_broadcast 函数会唤醒等待在条件变量上的所有线程。
- 条件变量的销毁。条件变量的销毁函数是pthread_cond_destroy,函数声明:
#include <pthread.h> int pthread_cond_destroy(pthread_cond_t *cond);
- 参数:cond是指向要销毁的条件变量的指针。
- 该函数用于销毁条件变量,释放与其关联的资源。在使用pthread_cond_t类型的条件变量后,应该在不需要使用它时,调用该函数进行销毁。销毁条件变量时,应该保证所有与其关联的线程已经退出并不再使用该条件变量。
- 找出1~20中能整除3的整数:
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;/*初始化互斥锁*/ pthread_cond_t cond = PTHREAD_COND_INITIALIZER;/*初始化条件变量*/ void *thread1(void *); void *thread2(void *); int i = 1; int main(void) { pthread_t t_a; pthread_t t_b; pthread_create(&t_a, NULL, thread2, (void *)NULL);//创建线程t_a pthread_create(&t_b, NULL, thread1, (void *)NULL); //创建线程t_b pthread_join(t_b, NULL);/*等待进程t_b结束*/ pthread_mutex_destroy(&mutex); pthread_cond_destroy(&cond); exit(0); } void *thread1(void *junk) { for (i = 1; i <= 20; i++) { pthread_mutex_lock(&mutex);//锁住互斥量 if (i % 3 == 0) pthread_cond_signal(&cond); //唤醒等待条件变量cond的线程 else printf("thead1:%d\n", i); //打印不能整除3的i pthread_mutex_unlock(&mutex);//解锁互斥量 sleep(1); } } void *thread2(void *junk) { while (i < 20) { pthread_mutex_lock(&mutex); if (i % 3 != 0) pthread_cond_wait(&cond, &mutex);//等待条件变量 printf("------------thread2:%d\n", i); //打印能整除3的i pthread_mutex_unlock(&mutex); sleep(1); i++; } }
信号量
- POSIX 信号量与 System V 信号量的区别如下:
- 对 POSIX 来说,信号量是个非负整数,常用于线程间同步。而 System V 信号量则是一个或多个信号量的集合,它对应的是一个信号量结构体,这个结构体是为 System V IPC 服务的,信号量只不过是它的一部分,常用于进程间同步。
- POSIX 信号量的引用头文件是 "<semaphore.h>",而 System V 信号量的引用头文件是 "<sys/sem.h>"。
- 从使用的角度,System V 信号量的使用比较复杂,而 POSIX 信号量使用起来相对简单。
网络编程
- 由于计算机网络的迅速发展和互联网的广泛应用。在互联网的发展过程中,人们需要开发各种类型的网络应用程序来满足不同的需求,例如电子邮件、文件传输、网页浏览器等等。 而这些网络应用程序需要通过计算机网络进行数据传输和处理,因此需要有一种标准的网络编程模型和工具来支持它们的开发。Linux作为一种自由和开放的操作系统,拥有丰富的网络编程资源和工具,因此成为了网络编程的重要平台之一。
- TCP/IP协议是为了解决不同系统的计算机通信而提出的一个标准,不同系统的计算机采用了同一种协议后,就能相互进行系统通信,从而建立网络连接,实现资源共享和网络通信。
协议簇和地址簇
- 协议簇(Protocol Family)是指一组协议的集合,而地址簇(Address Family)是指网络地址的表示方式。在计算机网络编程中,常用的协议簇和地址簇有以下几种:
协议簇
- IPv4协议簇:包括TCP、UDP、ICMP等协议,使用32位的IP地址标识网络中的主机和路由器。
- IPv6协议簇:与IPv4类似,但使用128位的IP地址。
- Unix域协议簇:用于在同一台计算机上的进程间通信,不需要通过网络传输数据。
地址簇
- IPv4地址簇:使用32位的二进制数表示,通常以点分十进制形式表示,如192.168.0.1。
- IPv6地址簇:使用128位的二进制数表示,通常以冒号分隔的16进制数表示,如2001:0db8:85a3:0000:0000:8a2e:0370:7334。
- Unix域地址簇:使用文件路径表示,如/tmp/my_socket。
- 在套接字编程中,常常需要指定协议簇和地址簇,以便创建相应的套接字。常用的协议簇常量有AF_INET(IPv4协议簇)、AF_INET6(IPv6协议簇)、AF_UNIX(Unix域协议簇)等; 常用的地址簇常量有PF_INET(IPv4地址簇)、PF_INET6(IPv6地址簇)、PF_UNIX(Unix域地址簇)等。
套接字
TCP套接字编程的基本步骤
TCP套接字编程的基本步骤在客户端和服务端的实现中有所不同。下面分别介绍客户端和服务端的基本步骤:
客户端
- 创建套接字:使用socket函数创建一个TCP套接字。该函数返回一个文件描述符,用于后续的套接字操作。
- 连接服务器:使用connect函数连接服务器端。该函数需要指定服务器端的IP地址和端口号。
- 进行通信:使用套接字进行数据的发送和接收。可以使用send和recv函数进行数据的发送和接收。在发送数据时,需要指定数据的长度和目标地址;在接收数据时,需要指定缓冲区的长度和来源地址。
- 关闭套接字:使用close函数关闭套接字,释放资源。
服务端
- 创建套接字:使用socket函数创建一个TCP套接字。该函数返回一个文件描述符,用于后续的套接字操作。
- 绑定套接字:使用bind函数将套接字与本地IP地址和端口号绑定。如果不需要指定本地IP地址和端口号,则可以省略此步骤。
- 监听套接字:使用listen函数将套接字设为监听状态,等待客户端的连接请求。listen函数指定了队列中等待连接的最大数量。
- 接受连接:使用accept函数等待客户端的连接请求,如果有连接请求则接受该连接,并返回一个新的套接字用于与客户端进行通信。
- 进行通信:使用新的套接字进行数据的发送和接收。可以使用send和recv函数进行数据的发送和接收。在发送数据时,需要指定数据的长度和目标地址;在接收数据时,需要指定缓冲区的长度和来源地址。
- 关闭套接字:使用close函数关闭套接字,释放资源。 需要注意的是,客户端和服务端在进行通信时,需要遵循一定的通信协议。通常采用的是一种类似于HTTP的请求-响应协议,即客户端发送请求给服务器端,服务器端接收并处理请求,并将处理结果发送给客户端。
Socket简介
- socket是一套用于不同主机间通信的API,它工作在我们的TCP/IP协议栈之上。Socket编程可以应用于各种网络通信场景,如Web服务器、邮件服务器、文件传输协议(FTP)等。它是构建互联网和分布式应用的基础技术之一,也是计算机科学领域中重要的网络编程技术之一。
- Socket API包含了一系列用于套接字编程的函数,可以在不同的操作系统和网络上使用。 Socket提供了两种常用的网络通信模型:面向连接的TCP协议和无连接的UDP协议。TCP协议提供了可靠的、面向连接的数据传输服务,而UDP协议则提供了无连接、不可靠的数据传输服务。 在套接字编程中,需要创建一个套接字(socket),并使用bind()函数绑定IP地址和端口号,然后使用listen()函数开启监听,等待客户端连接。当客户端连接时,可以使用accept()函数接受连接请求,并使用send()和recv()函数进行数据传输。当数据传输结束后,需要使用close()函数关闭连接。
socket()
-
用于创建一个TCP套接字,语法如下:
#include <sys/types.h> #include <sys/socket.h> int socket(int domain, int type, int protocol);
- 其中,domain参数表示协议族(通常为AF_INET或AF_INET6),type参数表示套接字类型(通常为SOCK_STREAM),protocol参数表示协议类型(通常为0,表示自动选择合适的协议)。
bind()
-
用于将一个本地地址和端口号绑定到TCP套接字上,语法如下:
#include <sys/types.h> #include <sys/socket.h> int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 其中,sockfd参数表示要绑定的TCP套接字,addr参数表示要绑定的本地地址和端口号,addrlen参数表示addr结构体的大小。
listen()
-
用于将TCP套接字设置为监听状态,等待其他应用程序的连接请求,语法如下:
#include <sys/types.h> #include <sys/socket.h> int listen(int sockfd, int backlog);
- 其中,sockfd参数表示要设置为监听状态的TCP套接字,backlog参数表示连接请求队列的最大长度。
accept()函数
-
用于接收其他应用程序的连接请求,并建立一个新的TCP连接,语法如下:
#include <sys/types.h> #include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
其中,sockfd参数表示已经设置为监听状态的TCP套接字,addr参数用于存储远程连接的地址和端口号,addrlen参数用于存储addr结构体的大小。
connect()
-
用于发起一个TCP连接请求,并建立一个与远程主机的TCP连接,语法如下:
#include <sys/types.h> #include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 其中,sockfd参数表示要发起连接的TCP套接字,addr参数表示要连接的远程主机地址和端口号,addrlen参数表示addr结构体的大小。
send()
-
用于将数据发送到已经建立的TCP连接中,语法如下:
#include <sys/types.h> #include <sys/socket.h> ssize_t send(int sockfd, const void *buf, size_t len, int flags);
其中,sockfd参数表示已经建立的TCP连接,buf参数表示要发送的数据缓冲区,len参数表示要发送的数据长度,flags参数表示发送数据时的选项(通常为0)。
recv()
-
用于从已经建立的TCP连接中接收数据,语法如下:
#include <sys/types.h> #include <sys/socket.h> ssize_t recv(int sockfd, void *buf, size_t len, int flags);
其中,sockfd参数表示已经建立的TCP连接,buf参数表示接收数据的缓冲区,len参数表示缓冲区的大小,flags参数表示接收数据时的选项(通常为0)。
close()
-
用于关闭已经建立的TCP连接,语法如下:
#include <sys/types.h> #include <sys/socket.h> int close(int sockfd);
其中,sockfd参数表示要关闭的TCP连接。
read()
- 用于从已连接的套接字中读取数据,函数原型为:
#include <sys/types.h> #include <sys/socket.h> int read(int sockfd, void *buf, size_t count)。
- 其中,sockfd是套接字文件描述符,buf是数据存储的缓冲区,count是要读取的数据字节数。函数返回值为实际读取的字节数,如果返回值为0表示连接已关闭,如果返回值为-1表示发生了错误。
write()
-
write()函数:用于向已连接的套接字中写入数据,函数原型为:
#include <sys/types.h> #include <sys/socket.h> int write(int sockfd, const void *buf, size_t count)。
- 其中,sockfd是套接字文件描述符,buf是要写入的数据缓冲区,count是要写入的数据字节数。函数返回值为实际写入的字节数,如果返回值为-1表示发生了错误。
sendto
-
sendto()函数:用于向指定的地址发送数据,函数原型为:
#include <sys/types.h> #include <sys/socket.h> int sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen)。
- 其中,sockfd是套接字文件描述符,buf是要发送的数据缓冲区,len是要发送的数据字节数,flags是可选标志位,dest_addr是目标地址,addrlen是地址长度。
- 函数返回值为实际发送的字节数,如果返回值为-1表示发生了错误。
ioctlsocket()
-
ioctlsocket()函数:用于控制套接字的属性,函数原型为:
#include <sys/types.h> #include <sys/socket.h> int ioctlsocket(int sockfd, long cmd, u_long *argp)。
- 其中,sockfd是套接字文件描述符,cmd是要执行的命令,argp是可选参数。函数返回值为0表示成功,-1表示发生了错误。
getsockopt()、setsockopt()
-
getsockopt()和setsockopt()函数:用于获取和设置套接字选项,函数原型分别为:
#include <sys/types.h> #include <sys/socket.h> int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen) int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen)。
- 其中,sockfd是套接字文件描述符,level是选项所属的协议层,optname是选项名,optval是选项值,optlen是选项值的长度。函数返回值为0表示成功,-1表示发生了错误。
TCP客户端实验
TCP服务器实验
多路复用机制
- 在早期的计算机系统中,I/O 操作是阻塞的,即当一个进程需要从磁盘或网络中读取数据时,进程会一直等待直到数据准备好为止。这样的阻塞式 I/O 操作会导致 CPU 时间的浪费,因为进程在等待 I/O 完成时无法做其他有用的工作。为了解决这个问题,早期的操作系统引入了非阻塞 I/O 和异步 I/O 的概念。在非阻塞 I/O 中,当一个进程发起一个 I/O 操作后,它可以继续执行其他任务,而不必一直等待 I/O 完成。但是,在非阻塞 I/O 中,当 I/O 操作未完成时,进程需要不断地轮询(poll)来检查 I/O 是否完成,这样会导致 CPU 时间的浪费。多路复用机制的产生正是为了解决这个问题。它可以使一个进程同时监视多个文件描述符,当其中一个文件描述符准备好时,就可以立即得到通知,从而避免了不断轮询的开销。这样,一个进程就可以同时处理多个 I/O 事件,从而提高了系统的并发处理能力。 多路复用机制已经成为了现代操作系统中的重要组成部分,被广泛应用于网络编程、服务器开发等领域。
- 常见的多路复用机制包括以下三个函数:select()、poll()、epoll()
select()函数
- 在 Linux 系统中,select() 是一种多路复用机制,它用于监视一组文件描述符的读写状态,当其中任何一个文件描述符就绪时,它就会返回。函数声明:
#include <sys/select.h> #include <sys/time.h> #include <sys/types.h> #include <unistd.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- 参数含义:
- nfds是待检测的文件描述符集合中所有文件描述符的最大值加 1;
- readfds、writefds 和 exceptfds 分别是待检测的可读、可写和异常文件描述符集合,它们都是 fd_set 类型的指针。
- 返回值:就绪 socket 描述符的数目,超时返回0,出错返回-1。
- 参数含义:
- select的缺点:
- 每次调用select,都需要把被监控的fds集合从用户态空间拷贝到内核态空间,高并发场景下这样的拷贝会使得消耗的资源是很大的。
- 每次在 select() 函数返回后,都要通过遍历文件描述符来获取已经就绪的 socket 。
- select支持的文件描述符数量有限,默认是1024。参见/usr/include/linux/posix_types.h中的定义:#define __FD_SETSIZE 1024。
poll()函数
- 在 Linux 系统中,poll()也是一种多路复用机制,它用于监视一组文件描述符的读写状态,当其中任何一个文件描述符就绪时,它就会返回。函数声明:
#include <signal.h> #include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 参数含义:
- fds 是一个结构体数组,用于存储待检测的文件描述符集合及其对应的事件类型(可读、可写等)。nfds 表示待检测的文件描述符数量,timeout 表示超时时间。
- 返回值:如果在指定时间内没有任何文件描述符就绪,poll() 函数会返回 0。
- 参数含义:
- poll() 使用一个结构体数组来传递文件描述符信息,这就解决了select的fds集合大小1024限制问题。
epoll()
- 在 Linux 系统中,epoll() 是最新、最高效的多路复用机制,epoll() 函数分为三个 API:epoll_create()、epoll_ctl() 和 epoll_wait(),函数声明:
#include <sys/epoll.h> int epoll_create(int size);
- 参数含义:
- size 参数表示创建 epoll 对象时要指定的大小,通常会设置为大于 0 的整数。
- 返回值:如果成功,返回poll 专用的文件描述符,否者失败,返回-1。
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 参数epfd: epoll 专用的文件描述符,epoll_create()的返回值
- 参数op: 表示动作,用三个宏来表示:
- EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
- 参数fd: 需要监听的文件描述符
- 参数event: 告诉内核要监听什么事件,struct epoll_event 结构如:
- events可以是以下几个宏的集合:
- EPOLLIN :表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET :将 EPOLL 设为边缘触发(Edge Trigger)模式,这是相对于水平触发(Level Trigger)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里
- 返回值:0表示成功,-1表示失败。
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 参数epfd: epoll 专用的文件描述符,epoll_create()的返回值
- 参数events: 分配好的 epoll_event 结构体数组,epoll 将会把发生的事件赋值到events 数组中(events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)。
- 参数maxevents: maxevents 告之内核这个 events 有多少个 。
- 参数timeout: 超时时间,单位为毫秒,为 -1 时,函数为阻塞。
- 返回值:如果成功,表示返回需要处理的事件数目,如果返回0,表示已超时,如果返回-1,表示失败。
- 参数含义: