Linux系统编程

文章目录

GCC和库

1、GCC

​ GCC 编译器是 Linux 系统下最常用的 C/C++ 编译器,大部分 Linux 发行版中都会默认安装,GCC 编译器通常以 gcc 命令的形式在终端(Shell) 中使用。

1、编译步骤

​ 从 hello.c 到 hello(或a.out) 文件,必须经历 hello.i,hello.s,hello.o,最后才得到 hello(或a.out) 文件,分别对应着预处理,编译,汇编和链接 4 个步骤,整个过程如图所示。

8B6ucTySc1LeUXYAsBvpuA

​ 这 4 步大致的工作内容如下:

  1. 预处理,C 编译器对各种预处理命令进行处理,包括头文件包含,宏定义的扩展,条件编译的选择等。
  2. 编译:将预处理得到的源代码进行语法词法分析,“翻译转换”产生出机器语言的目标程序,得到机器语言的汇编文件。
  3. 汇编:将汇编代码翻译成机器码,但是还不可以运行。
  4. 链接:处理可重定位文件,把各种符号引用和符号定义转换成为可执行文件中的合适信息,通常是虚拟地址。
2、编译过程

​ 下面根据 hello.c 这个示例,跟踪一下其中的细节。

1、预处理

gcc 命令加上 -E 参数,可以得到预处理文件,输入下列命令:

gcc -E hello.c -o hello.i

​ 将会产生 hello.i 文件,这就是 hello.c 经过预处理后的文件。一个原本几行的代码,经过预处理,得到了一个几千行的预处理文件,文件开的内容如图所示。

JN0kVTcxYIT93zpmMGeT0w

​ 其余部分内容用 vi 打开后查看。可以看到,hello.c 经过预处理后得到的 hello.i 文件,处理原本的几行代码之外,还包含了许多额外的变量,函数等等,这些都是预处理器处理的结果。

2、编译

​ 在 gcc 编译参数加上 -S,可以将 hello.i 编译成 hello.s 文件。命令如下:

gcc -S hello.i

​ hello.s 是一个汇编文件,可用 vi 编辑器打开查看,如图所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-r3n5IASx-1626960548133)(https://i.loli.net/2021/04/29/MBjhemL17TlOPAt.png)]

​ 可以看到,改文件内容都是汇编语言。

3、汇编

​ 得到了汇编文件后,通过 gcc 就可以得到机器码了。在终端输入下列命令,可以得到 hello.o 文件

gcc -c hello.s
4、链接

​ 尽管已经得到了机器码,单证文件却还是不可以运行的,必须经过链接才能运行。

​ 在终端输入下列命令,将会得到可执行文件 a.out。

gcc hello.o

​ 链接得到 a.out 文件

​ a.out 是 gcc 默认输出文件名称,可以通过 -o 参数指定新的文件名。例如加上 “-o hello” 参数,将会生成 hello 文件,这个文件和 a.out 实际上是一样的。

2、库

​ 库:库是指在我们的应用中,有一些公共代码是需要反复使用,就把这些代码编译为“库”文件;在链接步骤中,链接器将从库文件取得所需的代码,复制到生成的可执行文件中。

​ Linux 中常见的库文件有两种,一种 .a 为后缀,为静态库,另一种以 .so 为后缀,为动态库。

1、目标文件

​ 在解释静态库和动态库之前,需简单了解一下什么是目标文件。目标文件常常按照特定格式来组织,在 Linux 下,它是 ELF 格式(可执行可链接格式)。

​ 而通常目标文件有三种形式:

  • 可执行目标文件(exe):即我们通常所认识的,可直接运行的二进制文件。
  • 可重定位目标文件(relocatable):包含了二进制的代码和数据,可以与其他可重定位目标文件合并,并创建一个可执行目标文件。
  • 共享目标文件(shared object):它是一种在加载或运行时进行链接的特殊可重定位目标文件。
2、什么是静态库

​ 前面多次提到的可重定位目标文件以一种特定的方式打包成一个单独的文件,并且在链接生成可执行文件时,从这个单独的文件中“拷贝”它所需要的内容到最终的可执行文件中。这个单独的文件,称为静态库。Linux 中这类库的名字一般是 libxxx.a。

1、静态库的创建步骤

add.c

int add(int a,int b){
    return a+b;
}

sub.c

int sub(int a,int b)
{
    return a-b;
}

执行命令

//先编译成可重定位目标文件
gcc -c add.c -o add.o
gcc -c sub.c -o sub.o
//利用 ar 工具创建静态库:ar rcs lib库名.a 所有可重定位目标文件
ar rcs libmath.a add.o sub.o
2、使用静态链接构建我们的可执行文件

main.c

#include<stdio.h>
#include"math.h"
int main(int argc,char* argv[])
{
    int a = 10;
    int b = 5;
    printf("a + b = %d\n",add(a,b));
    printf("a - b = %d\n",sub(a,b));
    return 0;
}

math.h

#ifndef _MATH_H
#define _MATH_H
int add(int a,int b);
int sub(int a,int b);
#endif

​ 代码计算 a,b的和差并打印结果。由于代码中用到了 add 函数和 sub 函数,它位于我们自己的库 libmath.a 中,因此编译时需要加上 -l math -L ./,-l 指定库名, -L 指定库路径。

gcc -c main.c - o main.o
gcc -static main.o -o main -l math -L ./    

​ 在这个过程中,就会用到的静态库 libmath.a。这个过程做了什么呢?首先第一条命令会将 maic.c 编译成可重定位目标文件 main.o,第二条命令的 static 参数,告诉链接器应该使用静态链接,-l math 参数表明链接 libmath.a 这个库(类似的,如果要链接 libxxx.a,使用 -l xxx 即可),-L 指定这个静态库所在的路径。由于 main.c 中使用了 libmath.a 中的 add和sub 函数,因此链接时,会将 libmath.a 中需要的代码 “拷贝” 到最终的可执行文件 main 中。

​ 特别注意,必须把 -l math 放在后面。放在最后时它是这样的一个解析过程:

  • 链接器从左往右扫描可重定位目标文件和静态库
  • 扫描 main.o 时,发现两个未解析的符号 add 和 sub ,记住这两个未解析的符号
  • 扫描 libmath.a,找到了前面未解析的符号,因此提取相关代码
  • 最终没有任何未解析的符号,编译链接完成

那如果将 -l math 放在前面,又是怎样的情况呢?

  • 链接器从左往右扫描可重定位目标文件和静态库
  • 扫描 libmath.a,由于前面没有任何未解析的符号,因此不会提取任何代码
  • 扫描 main.o,发现未解析的符号 add 和 sub
  • 扫描结束,还有两个未解析的符号,因此编译链接报错

如果把 -l math 放在前面,编译结果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CinzWxkI-1626960548137)(https://i.loli.net/2021/04/29/7C46pbn5FokEexi.png)]

​ 我们看看最终生成的文件大小:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y2z9hG1E-1626960548140)(https://i.loli.net/2021/04/29/tixveM72roHEgfY.png)]

​ 生成的可执行文件大小为 892k

​ 由于最终生成的可执行文件中已经包含了 add 和 sub 相关的二进制代码,因此这个可执行文件在一个没有 libmath.a的linux系统中也能正常运行。

3、什么是动态库

​ 动态库和静态库类似,但它并不在链接时将需要的二进制代码都“拷贝”到可执行文件中,而是仅仅“拷贝”一些重定位和符号表信息,这些信息可以在程序运行时完成真正的链接过程。linux 中这类库的名字一般是 libxxx.so。

1、动态库的创建步骤

​ 这里我们延用讲解静态库时使用的代码

//先编译成可重定位目标文件(生成与位置无关的代码 -fPIC)
gcc -c add.c -o add.o -fPIC
gcc -c sub.c -o sub.o -fPIC
//使用 gcc -shared 制作动态库
gcc -shared -o lib库名.so add.o sub.o    
2、使用动态链接构建我们的可执行文件

​ 通常我们编译的程序默认就是使用动态链接

gcc main.c -o main -l math -L ./

​ 我们来看最终生成的文件大小:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OsDYCCJt-1626960548141)(https://i.loli.net/2021/04/29/7WRDSNq5MVz3UoQ.png)]

​ 可以看出,通过动态链接的程序只有 8.7k

​ 另外我们还可以通过 ldd 命令来观察可执行文件连接梁哪些动态库:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mOxOzkpP-1626960548142)(https://i.loli.net/2021/04/29/IFiHUjOnDEpGcvz.png)]

​ 正因为我们并没有把 libmath.so中的二进制代码“拷贝”可执行文件中,我们的程序在其他没有上面的动态库时,将无法正常运行。

3、找不到动态库

​ 运行可以执行程序 ./main 出错!!!

img

img

./main:error while loading shared libraries:libmath.so:cannot open shared object file:NO such file or directory

​ 原因:

​ 链接器:工作于链接阶段,工作时需要 -l 和 -L

​ 动态链接器:工作于程序运行阶段,工作时需要提供动态库所在目录位置。

​ 解决方式:

  1. 通过环境变量:
export LD_LIBRARY_PATH = 动态库路径
./main		//成功(临时生效,终端重启环境变量失败)
  1. 永久生效:写入终端配置文件 .bashrc 建议使用绝对路径。
vi ~/.bashrc
写入 export LD_LIBRARY_PATH = 动态库路径 保存
. .bashrc 或 source .bashrc 或重启终端 --> 让修改后的 .bashrc 生效
./main 成功    
  1. 拷贝自定义动态库到 /lib
  2. 配置文件法
sudo vi /etc/ld.so.conf
写入动态库绝对路径 保存
sudo ldconfig 	使配置文件生效
./main 成功
4、静态库和动态库的区别
  • 可执行文件大小不一样

  • 占用磁盘大小不一样

  • 扩展性与兼容性不一样

  • 依赖不一样

  • 加载速度不一样

  • 复杂性不一样

makefile脚本语言

1、初识makefile

1、makefile 是什么

makefile 是一种脚本语言

2、makefile 干什么用的?

做工程项目管理,让你更方便地去编译你的项目文件

2.makefile 使用(1223)

1个规则2个基本原理2个函数3个自动变量和模式规则

1、规则
目标文件:依赖条件
(Tab键)命令
2、基本原理

基本原理1:若想生成目标,必须检查规则中的依赖条件是否存在,如果不存在,则寻找是都有规则来生成该依赖文件

基本原理2:检查规则中的目标是否需要更新,必须先检查它的依赖条件,依赖条件中任意一个被更新,则我们的规则必须更新

3、两个函数
//$():定义变量                       //wildcard:通配符
//将所有的 .c 文件用 src 表示
src = $(wildcard *.c)               //src = main.c add.c sub.c mul.c
//将src中 .c 文件换成 .o 文件
obj = $(patsubst %.c,%.o,$(src))    //ogj = main.o add.o sub.c mul.o
4、三个变量(自动变量)

1、$@ :在规则的命令中,表示规则的目标

//eg
add.o:add.c
    gcc -c add.c -o $@

2、$^ :在规则的命令中,表示规则的所有依赖条件

//eg
add.o:add.c
    gcc -c $^ -o $@

3、$< :在规则的命令中,表示规则的第一个依赖条件。(如果用在模式规则中,它可以将我们依赖条件列表的依赖条件依次取出)

//eg
add.o:add.c
    gcc -c $< -o $@

4、三个变量变化

//原
main.o:main.c
	gcc -c main.c -o main.o
add.o:add.c
	gcc -c add.c -o add.o
sub.o:sub.c
	gcc -c sub.c -o sub.o
mul.o:mul.c
	gcc -c mul.c -o mul.o

//现
%.o:%c
    gcc -c $< -o $@

makefile文件

//#makefile语言的备注符号是:#
//#一般来说,makefile有一个最终目标,他的第一行规则中的目标作为他的最终目标
ALL:main                            //用 ALL 指定改文件的最终目标是 main
WALL = -Wall                        //输出所有错误
src = $(wildcard *.c)               //src = main.c add.c sub.c mul.c
obj = $(patsubst %.c,%.o,$(src))    //ogj = main.o add.o sub.c mul.o

main:$(obj)
	gcc main.o add.o sub.o mul.o -o main
main.o:main.c
	gcc -c main.c -o main.o
add.o:add.c
	gcc -c add.c -o add.o
sub.o:sub.c
	gcc -c sub.c -o sub.o
mul.o:mul.c
	gcc -c mul.c -o mul.o
//clean 是伪目标,没有依赖条件 使用时: make 
clean
.PHONY:clean            //声明伪目标
clean:
    rm -rf *.o main     //删除 *.o和main文件

math.h文件

#ifndef _MATH_H
#define _MATH_H
int add(int a,int b);
int sub(int a,int b);
int mul(int a,int b);
#endif

math.c 文件

#include<stdio.h>
#include"math.h"

int main(int argc, char* argv[])
{
	int a = 10;
	int b = 5;
	printf("a + b = %d\n",add(a,b));
	printf("a - b = %d\n",sub(a,b));
	printf("a * b = %d\n",mul(a,b));
	return 0;
}

文件IO

​ Linux中,一切皆文件,文件为操作系统服务和设备提供了一个简单而一致的接口。这意味着程序完全可以像使用文件那样使用磁盘文件,串行口,打印机和其他设备。

​ 也就是说,大多数情况下,你只需要5个函数:open,close,read,write和ioct1(驱动程序相关)

1、目录和文件

文件通常由两部分组成:内容+属性,属性即管理信息:包括文件的创建修改日期和访问权限等。

属性均保存在 inode 节点中。 inode -“索引节点”,存储文件的元信息,比如:文件的创建者,文件的创建日期,文件的长度和文件在磁盘上存放的位置等等。每个 inode 都有一个号码,操作系统用 inode 号码来识别不用的文件。ls -i 查看 inode 号。

Linux 文件系统将文件索引节点号和文件名同时保存在目录中。所以,目录只是将文件的名称和它的索引节点号结合在一起的一张表,目录中每一对文件名称和索引节点号称为一个连续

BhASsZY_TzvEDIemFxvO0g

当文件硬链接数变为零,意味文件删除,磁盘空间变成可用空间。

2、底层文件访问

运行中的程序称为进程,每个进程都有与之关联的文件描述符。

文件描述符:一些小值整数,通过他们访问打开的文件或设备。一个进程开始运行会有三个文件描述符:

数值操作符号
0标准输入STDIN_FILENO
1标准输出STDOUT_FILENO
2标准错误STDERR_FILENO

文件描述符的变化范围是:0~OPEN_MAX-1

3、系统调用

问:什么是系统调用?

答:由操作系统实现并提供给外部应用程序的编程接口(API)。是应用程序同系统之间数据交互的桥梁。

C 标准库函数和系统函数调用关系:一个helloworld如何打印到屏幕

0

4、常见的系统调用

1、open系统调用
作用

​ 创建/打开一个文件(文件或设备)。

运行机制

​ open建立一条到文件或设备的访问路径。成功后可获得供 read,write和其他系统调用使用的唯一的文件描述符。此文件描述符进程唯一

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:用于指定创建文件的权限,O_CREATE 才使用
*/

​ flags参数通过必需文件访问模式与其他可选模式相结合的方式来指定。首先必须指定如下文件访问模式之一。

必选模式说明
O_RDONLY以只读方式打开
O_WRONLY以只写方式打开
O_RDWR以读写方式打开
可选模式组合说明
O_APPEND把写入数据追加在文件的末尾
O_TRUNC打开文件时把文件长度设置为零,丢弃已有内容
O_CREAT如果需要,就按参数mode中给出的访问模式创建文件
O_EXCL与O_CREAT一起使用,确保创建文件的原子操作。如果文件存在,创建将失败

​ 创建文件时,指定文件访问权限。权限同时受 umask 影响。结论为:

文件权限 = mode & ~umask

使用头文件:<fcntl.h>

open函数调用成功返回得到的文件描述符,失败返回 -1,并把 erroe设置成一个合适的值。

2、close系统调用
作用

​ 终止文件描述符 fd 和对应文件(文件或设备)的关联。文件描述符被释放并能够被其他文件重新使用。

#include<unistd.h>
int close(int fd);
/*
成功返回 0 
出错返回 -1,并把 errno 设置成一个合适的值。
*/
3、read系统调用
作用

​ 从文件描述符 fd相关联的文件中读取前 count 个字节到缓冲区buf中。

#include<unistd.h>
size_t write(int fd,const void* buf,size_t count);
/*
成功它返回实际读入的字节数,这可能会小于请求的字节数。
0表示位读入任何数据,已到达了文件尾部。
-1 表示出错,错误代号在全局变量 errno里
*/
4、write系统调用
作用

​ 把缓冲区 buf 的前 count 个字节写入与文件描述符 fd 相关联的文件中

#include<unistd.h>
size_t write(int fd,const void *buf,size_t count);
/*
成功返回写入的字节数,可能小于count
0表示未写入任何数据
失败返回-1,错误代号在全局变量errno里。
*/
5、dup和dup2的系统调用
作用

​ 提供一种赋值文件描述符的方法,使我们通过两个或者更多不同的描述符来访问同一个文件,主要用于多个进程间进行管道通信。

#include<unistd.h>
int dup(int oldfd);
int dup2(int oldfd,int newfd);
/*
当调用 dup 函数时,内核在进程中创建一个新的文件描述符,此描述符是当前可用文件描述符的最小数值,这个文件描述符指向olffd所拥有的的文件表项。
dup2和dup的区别就是可以用 newfd 参数指定新描述符的数值,如果 newfd已经打开,则先将其关闭。如果 newfd 等于 oldfd,则 dup2 返回 newfd,而不是关闭它。dup2 函数返回的新文件描述符同样与参数 oldfd 共享同一文件表项。
*/
6、lseek系统调用
作用

​ lseek 对文件描述符 fd 的读写指针进行设置。也就是说,设置文件的下一个读写位置。可根据绝对位置和相对位置(当前位置或文件尾部)进行设置。

#include<sys/types.h>
#include<unistd.h>
off_t lseek(int fd,off_t offset,int whence);
/*
offset 参数用来指定位置,而whence 参数定义该偏移值的用法。
whence 可取值如下:
SWWK_SET:offset 是一个绝对位置(从文件的头位置开始计算)
SEEK_CUR:offset 是相对于当前位置的一个绝对位置(从文件的当前位置开始计算)
SEEK_END:offset 是相对于文件尾的一个相对位置(从文件的末尾位置开始计算)

成功返回从文件头到文件指针被设置处的字节偏移值
失败返回 -1
lseek 可以用于拓展文件大小(但必须引起io操作)
*/
7、fstat,stat 和 lstat 系统调用

​ stat 和 lstat 均通过文件名查询状态信息,当文件名是符号链接时,lstat 返回的是符号链接本身的信息,而 stat 返回的是该链接指向的文件的信息。

#include<sys/type.h>
#include<sys/stat.h>
#include<unistd.h>
int stat(const char *pathname,struct stat *buf);
int fstat(int fd,struct stat *buf);
int lstat(const char *pathname,struct stat *buf);
struct stat{
    dev_t	st_dev;
    ino_t	st_ino;
    mode_t	st_mode;
    nlink_t	st_nlink;
    uid_t	st_uid;
    gid_t	st_gid;
    dev_t	st_rdev;
    off_t	st_size;
    blksize_t	st_blksize;
    blkcnt_t	st_blocks;
    time_t		st_atime;
    time_t		st_mtime;
    time_t		st_ctime;
}

​ 这里要特别提到的是,以上 st_mode 标志有一系列相关的宏,定义见 sys/stat.h 中,可用来测试文件类型,如:

img
8、错误处理

​ 许多系统调用和库函数都会因为各种各样的原因失败。他们失败时设置外部变量 errno 来命名失败原因。许多不同函数库都把这个变量用做报告错误的标准方法。

​ 注意:程序必须在函数报告出错之后立刻检查 errno 变量,因为它可能马上就被下一个函数调用多覆盖,即使下一个函数没有出错,也可能会覆盖这个变量。

常用错误代码的取值和含义如下

错误代码的取值含义
EPERM操作不允许
ENOENT文件或目录不存在
EINTR系统调用被中断
EAGAIN重试,下次可能成功!
EBADF文件描述符失效或本身无效
EIOI/O 错误
EBUSY设备或资源忙
EEXIST文件存在
EINVL无效参数
EMFILE打开的文件过多
ENODEV设备不存在
EISDIR是一个目录
ENOTDIR不是一个目录

​ 两个有效函数课报告出现的错误:strerror 和 perror

strerror 函数
作用

​ 把错误点好映射成一个字符串,该字符串对发生的错误类型进行说明

#include<string.h>
char *strerror(int errnum);
perror 函数
作用

​ perror 函数也把 error 变量中报告的当前错误映射成一个字符串,并把它输出到标准错误输出流。

#include<stdio.h>
void perror(const char *s);
/*
perror("test");
结果
test:Too many open files
*/

5、主函数参数

int main(int argc,char* argv[]);
/*
argc 是命令行参数的数量
argv 是具体的参数
*/

进程

1、进程相关内容概述

1、什么是进程(一段程序的执行过程)

​ 进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令,数据及其组织形式的描述,进程是程序的实体。

​ 进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region) 。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行时),它才能成为一个活动的实体,我们称其为进程。

​ 进程是操作系统总最基本,最重要的概念。是多道程序系统出现后,为了刻画系统内部出现的动态情况,描述系统内部各道程序的活动规律引进的一个概念,所有多道程序设计操作系统都建立在进程的基础上。

3、并发

​ 并发,在操作系统中,一个事件段中有多个进程都处于一启动运行到运行完毕之间的状态。担任一个时刻点上仍只有一个进程在运行。

​ 例如,当先,我们使用计算机可以边听音乐边聊天边上网。若笼统的将它们均看做一个进程的话,为什么可以同时运行呢,因为并发。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SjMgb29e-1626960548148)(…/MDFile/Linux 系统编程/Untitled.assets/0.png)]

3、单道程序设计

​ 所有进程一个一个排队执行。若 A 阻塞,B 只能等待,即使 CPU 处于空闲状态。而在人机交互时阻塞的出现是必然的。所有这种模型在系统资源利用上及其不合理,在计算机发展历史上存在不久,大部分被淘汰了。

4、多道程序设计

​ 在计算机内存中同时存放几道相互独立的程序,他们在管理程序控制下,相互穿插的运行。多道程序设计必须有硬件基础作为保证。

时钟中断即为多道程序设计模型的理论基础。并发时,任意进程在执行期间都不希望放弃 CPU 。因此系统需要一种强制让进程让出 CPU 资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。操作系统中的中断处理函数,来负责调度程序执行。

​ 在多道程序设计模型中,多个进程轮流使用 CPU(分时复用 CPU 资源)。而当下常见 CPU 为纳秒级,1s可以执行约10亿条指令。由于人眼的反应速度是毫秒级,所以看似同时在运行。

​ 1s = 1000 ms , 1ms = 1000us , 1us = 1000ns

5、虚拟内存

img

img

6、进程控制块 PCB

​ 我们知道,每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux 内核的进程控制块是 task_struct 结构体。

​ /user/src/linux-headers-3.13.0-32/include/linux/sched.h 文件中可以查看 struct task_struct 结构体定义。其内部成员有很多,我们重点掌握以下部分即可:

  • 进程id:系统中每个进程有唯一的id,在C语言中用 pid_t 类型表示,其实就是一个非负整数

  • 进程的状态:有就绪,运行,挂起等状态

  • 文件描述符:包含很多指向file结构体的指针

  • 进程切换时需要保存和恢复一些 CPU 寄存器

  • 描述控制终端的信息

  • 当前工作目标(Current Working Directory)

  • umask 掩码

  • 和信号相关的信息

  • 用户id和组id

  • 会话(Session) 和进程组

  • 进程可以使用欧冠的资源上限(Resource Limit)7、进程状态

7、进程状态
  • 运行(running)态:进程占有处理器正在运行。
  • 就绪(ready)态:进程具备运行条件,等待系统分配处理器以便运行。
  • 阻塞态/等待(wait)态:指进程具备运行条件,正在等待某个事件的完成。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ehCDow32-1626960548152)(…/MDFile/Linux 系统编程/Untitled.assets/CJP-Iq-pbcDSA79u-dwbYA.png)]

2、进程控制

1、进程的创建函数:fork()

​ 在讨论 fork() 函数之前,有必要先明确父进程和子进程两个概念,除了 0号进程(该进程是系统自举时由系统创建的)以外,Linux 系统中的任何一个进程都是由其他进程创建的,创建新进程的进程,即调用 fork() 函数的进程就是父进程,而新创建的进程就子进程。

​ Linux 系统允许任何一个用户进程创建子进程,创建成功后,子进程将存在于系统之中,并且独立于父进程,该子进程可以接受系统调度,可以得到分配的系统资源,系统也可以监测子进程的存在,并且赋予它与父进程同样的权利。

​ fork() 函数会创建一个新的进程,并从内核中为此进程分配一个新的可用的进程标识符 (PID) ,之后,为这个新进程分配进程空间,并将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共代码段,这时候,系统中又多一个进程,这个进程和父进程一样,两个进程都要接受系统的调度。由于在复制时复制了父进程的堆栈段,所以两个进程都停留在了 fork() 函数中,等待返回,因此,fork() 函数返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值是不一样的。

fork()函数
#include <unistd.h>
pid_t fork(void);
/*
fork() 函数不需要参数,返回值是一个进程标识符 (PID) 对于返回值,有以下三种情况:

对于父进程,fork() 函数返回新创建的子进程的 ID。

对于子进程,fork() 函数返回0。

如果创建出错,则fork() 函数返回 -1,子进程不被创建。
*/
实例
//创建1个子进程,该程序在父进程和子进程中分别输出不同的内容
#include<stdio.h>
#include<unistd.h>
int main(int argc,char* argc[])
{
    int pid =fork();
    if(pid==0)		//子进程
    {
        printf("I'm child,ID = %d,My parent ID = %d\n",getpid(),getppid());
    }
    else if(pid>0)	//父进程
    {
        printf("I'm parent,ID = %d,My parent ID =%d\n",getpid(),getppid());
        sleep(1);
    }
    return 0;
}
/*
I'm child,ID = 2735, My parent ID = 2257
I'm parent,ID = 2736, My parent ID = 2735
*/

​ 由于创建的新进程和父进程在系统看来是地位平等的两个进程,运行机会也是一样的,故不能够对其执行先后顺序进行假设,先执行哪个进程取决于系统的调度算法,示例中为了让父进程不那么快结束,所以加了 sleep(1) 语句,休眠1s 再结束,这样大家看到父子进程的关系 getpid() 是获得当前进程的 pid ,而 getppid() 则是获得父进程id

fork后父进程和子进程的异同

​ 父子进程之间在fork后。有哪些相同,有哪些相异之处呢?

​ 刚 fork() 之后:

​ 父子相同处:全局变量,.data,.text,栈,堆,环境变量,用户ID,宿主目录,进程工作目录…

​ 父子不同处:进程ID,fork返回值,父进程ID,进程运行时间

​ 似乎,子进程复制了父进程 0-3G 用户空间内容,以及父进程的 PCB,但 pid 不同。真的每 fork 一个子进程都要将父进程的 0-3G 地址空间完全拷贝一份,然后在映射至物理内存么?

​ 当然不是!父子进程间遵循读时共享写时复制的原则。

​ 现在的 Linux 内核在 fork() 函数时往往创建子进程时并不立即复制父进程的数据段和堆栈段,而是当子进程修改这些数据内容时复制操作才会发生,内核才会给子进程分配进程空间,将父进程的内容复制过来,然后继续后面的操作,这样的实现更加合理,对于那些只是为了复制自身完成一些工作的进程来说,这样做的效率会更高这也是现代操作系统的一个重要的概念——“写时复制”的一个重要体现。

2、进程的结束函数:exit() 函数

​ 当一个进程需要退出时,需要调用退出函数,Linux 环境下使用 exit() 函数退出进程,其函数原型如下:

#include<stdlib.h>
void exit(int status);
/*
$? 是 Linux shell 中的一个内置变量其中保存的是最近一次运行的进程的返回值,这个返回值有以下3中情况:

1. 程序中的 main 函数运行结束,$? 中保存 main 函数的返回值
2. 程序运行中调用 exit 函数结束运行, $? 中保存 exit 函数的参数
3. 程序异常退出 $? 中报异常出错的错误号。
*/

​ exit() 函数的参数表示进程的退出状态,这个状态的值是一个整型,保存在全局变量 $? 中。Linux 程序员可以通过 shell 得到已结束进程的结束状态,执行 “echo $ ?” 命令即可。

3、思考?

​ 一次 fork 函数调用可以创建一个子进程。那么创建 n 个子进程应该怎样实现呢?

​ 简单向, fork(int i=0;i<n;i++){fork()} 即可。但这样创建的是n个子进程么?

​ 通过该练习掌握框架:循环创建n个子进程,使用循环因子 i对子进程加以区分。

4、exec 函数族

​ fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动历程开始执行。调用 exec 并不创建新进程,所以调用 exec 前后该进程的 id 并未改变。

​ 将当前进程的 .text、.data 替换为所要加载的程序的 .text、.data,然后让进程从新的 .text 第一条指令开始执行,但进程ID 不变,换核不换壳

​ 其实有六种以 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,...);
 
int execv(const char *path,char *const argv[]);

int execvp(const char *file,char *const argv[]);

int execvpe(const char *file,char *const argv[]);

execlp 函数

​ 加载一个进程,借助PATH环境变量

int execlp(const char *file,const char *arg,...);
/*
成功:无返回
失败:返回 -1
参数1:要加载的程序的名字。该函数需要配合 PATH 环境变量来使用,当 PATH 中所有目录搜索后没有参数1则出错返回。
该函数通常用来调用系统程序。如:ls、date、cp、cat 等命令
*/
//使用程序名在 PATH 中搜索
execlp("ls","ls","-l","-h",NULL);
execl函数

​ 加载一个进程,通过路径+程序名来加载

int execl(const char* path,const char* arg,...);
/*
成功:无返回
失败:返回-1
对比 execlp,如加载 ls 命令带有 -l,-h 参数
*/
//使用参数1给出的绝对路径搜索
execl("/bin/ls","ls","-l","-h",NULL);
5、孤儿进程

​ 孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为 init 进程,称为 init 进程领养孤儿进程。

6、僵尸进程

​ 僵尸进程:进程终止,父进程尚未回收,子进程残留资源 (PCB) 存放于内核中,变成僵尸进程。

​ 特别注意,僵尸进程是不能使用kill 命令清除掉的。因为 kill 命令指示用来终止进程的,而僵尸进程已经终止。

7、进程回收
wait 函数

​ 一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的 PCB 还保留着,内核在其中保存了一些信息:如果是正常终止则保存退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用 wait 或 waitpid 获取这些信息,然后彻底清除掉这个进程。我们知道一个进程的退出状态可以在 Shell 中用特殊变量 $? 查看,因为 Shell 是它的父进程,当它终止时 Shell 调用 wait 或waitpid 得到它的退出状态同时彻底清除这个进程。

​ 父进程调用 wait 函数可以回收子进程终止信息。该函数有三个功能:

  1. 阻塞等待子进程退出
  2. 回收子进程残留资源
  3. 获取子进程结束状态(退出原因)
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int *status);
/*
成功:返回清理掉的子进程 ID
失败:返回-1(没有子进程)
*/

​ 当进程终止时,操作系统的隐式回收机制会:

1. 关闭所有文件描述符

2. 释放用户空间,分配的内存。内核的 PCB 仍存在。其中保存该进程的退出状态(正常终止->退出值;异常终止->终止信号)

​ 可使用 wait 函数传出参数 status 来保存进程的退出状态。借助宏函数来进一步判断终止的具体原因。宏函数可分为如下三组:

//第一组
WIFEXITED(status)		//为真-> 进程正常结束
    WEXITSTATUS(status)	//如上宏为真,使用此宏->获取进程退出状态(exit的参数)
//第二组
WIFSIGNALED(status)		//为真->进程异常终止
    WTERMSIG(status)	//如上宏为真,使用此宏->取得使进程终止的信号的编号
//第三组
WIFSTOPPED(status)		//为非真->进程处于暂停状态
    WSTOPSIG(status)	//如上宏为真,使用此宏->取得使进程暂停的那个信号的编号
    WIFCONTINUED(status)//如WIFSTOPPED(status)为真->进程暂停后已经继续运行
waitpid 函数

​ 作用同 wait,但可指定进程 id 为 pid 的进程清理,可以不阻塞。

#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t pid,int *status,int options);
/*
成功:返回清理掉的子进程 ID
失败:-1
特殊参数和返回情况
参数 pid:
>0:回收指定ID 的子进程
-1:回收任意子进程(相当于wait)
0:回收和当前调用 waitpid 一个组的所有子进程
<-1:回收指定进程组内的任意子进程
*/

​ 注意:一次 wait 或 waitpid 调用只能清理一个子进程,清理多个子进程应使用循环。

3、进程间通信

​ 进程间通信就是在不同进程之间传播或交换信息,那么不同进程之间存在着什么双方都可以访问的介质呢?首先,进程间通信至少可以通过传送,打开文件来实现,不同的进程通过一个或多个文件来传递信息,事实上,在很多应用系统里都使用了这种方法。但一般说来,进程间通信(IPC) 不包括这种似乎比较低级的通信方法。UNIX 系统中实现进程间通信的方法很多,而且不幸的是极少方法能在所有的 UNIX 系统中进行移植(唯一一种是半双工的管道,这也是最原始的一种通信方式)。而 Linux 作为一种新兴的操作系统,几乎支持所有的 UNIX 常用的进程间通信方法:管道,消息队伍,共享队列,共享内存,信号量,套接字等。其中,前4中主要用于一台机器上的进程间通信,而套接字则主要用于不同机器之间的网络通信。

1、常见的通信方式
1、管道pipe

管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。

2、命名管道FIFO

有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

3、消息队列 MessageQueue:

消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。

4、共享存储 SharedMemory:

共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

5、套接字 Socket

套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。

6、信号 signal

信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

2、管道

​ 管道,通常指无名管道,是 UNIX 系统 IPC 最古老的形式

1. 特点

​ 它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。

​ 它只能用于具有亲缘关系的进程之间的通信(即父子进程或者兄弟进程之间)。

​ 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的 read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

2. 原型
#include<unistd.h>
int pipe(int fd[2]);		//返回值:成功:0;失败:-1

​ 当一个管道建立时,它会创建两个文件描述符:fd[0] 为读而打开, fd[1] 为写而打开。如下图:

img

​ 要关闭管道只需将这两个文件描述符关闭即可,当最后一个使用它的进程关闭对它的引用是,pipe将自动撤销。

3、例子

​ 单个进程中的管道几乎没有任何用处。所以,通常调用 pipe 的进程接着调用 fork,这样就创建了父进程与子进程之间的 IPC 通道。如下图所示:

img

​ 父进程刚fork之后 父进程写子进程读

​ 若要数据流从父进程流向子进程,则关闭父进程的读端 (fd[0]) 与子进程的写端 (fd[1]);反之,则可以使数据流从子进程流向父进程。

​ 管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据:管道一端的进程顺利地将进程数据写入缓冲区,另一端的进程则顺序地读取数据,该缓冲区可以看做一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次,独处以后的数据就不复存在了。当缓冲区读空或者写满时,有一定的规则控制相应的读进程或写进程是否进入等待队列,当空的缓冲区有新数据写入或满的缓冲区有数据读出时,就唤醒等待队列中的进程继续读写。

4、管道的读写行为

​ 使用管道需要注意一下4中特殊情况(假设都是阻塞 I/O 操作,没有设置 O_NONBLOCK标志):

  1. 如果多有指向管道写端的文件描述符都关闭了(管道写端引用计数为0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据被读取后,再次 read 会返回0,就像读到文件末尾一样。
  2. 如果有指向管道写端的文件描述符没关闭(管道写端应用计数大于0),而持有管道写端的进程也没有想管道写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,知道管道中数据可读了才读取数据并返回。
  3. 如果所有指向管道读端的文件描述符都关闭了(管道读端引用计数为0),这时有进程向管道的写端 write,那么该进程会收到信号 SIGPIPE,通常会导致进程异常终止。
  4. 如果有指向管道读端的文件描述符没关闭(管道读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再次 write 会阻塞,知道管道中有空位置了才写入数据并返回。
总结

读管道:1 管道有数据,read 返回实际读到的字节数

​ 2 管道中无数据:

​ 1 管道写端被全部关闭,read 返回 0 (像读到文件结尾)

​ 2 写端没有全部被关闭,read阻塞等待(不久的将来可能有数据传递,此时会让出 cpu)

写管道:1 管道读端全部被关闭,进程异常终止(也可使用捕捉 SIGPIPE 信号,使进程不终止)

​ 2 管道读端没有全部关闭:

​ 1 管道已满。write 阻塞。

​ 2 管道未满,write 将数据写入,并返回实际写入的字节数。

3、有名管道(FIFO)

​ FIFO,也称为命名管道,它是一种文件类型。

1、特点
  1. FIFO 可以在无关的进程之间交换数据,与无名管道不同。
  2. FIFO 路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。
2、原型
#include<sys/stat.h>
int mkfifo(const char *pathname,mode_t mode);
//返回值:成功:0;失败-1

​ 其中的 mode 参数与 open 函数中的 mode 相同。一旦创建了一个 FIFO,就可以用一般的文件 I/O 函数操作它。

​ 当 open 一个FIFO时,是否设置非阻塞标志(O_NONBLOCK) 的区别:

​ 若没有指定 O_NONBLOCK(默认),只读 open 要阻塞到某个其他进程为写打开次 FIFO 。类似的,只写 open 要阻塞到某个其他进程为读而打开它,若制定了 O_NONBLOCK,则只读 open 立即返回。而只写 open 将出错返回 -1 如果没有进程已经为读而打开该 FIFO ,其 errno 置 ENXIO 。

3、例子

​ FIFO 的通信方式类似于在进程中使用文件来传输数据,只不过 FIFO 类似文件同时具有管道的特性。在数据读出时, FIFO 管道中同时清除数据,并且 “先进先出”。下面的例子演示了使用 FIFO 今次那个 IPC 的过程。

w_fifo.c

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>
#include<funt1.h>
#define BUFSIZE 1024
int main(int argc,char* argc[])
{
    char buf[BUFSIZE];
    int i = 0;
    int fd = open("myfifo",O_RDWR);
    while(1)
    {
        sprintf(buf,"hello world %dth\n",i++);
        write(fd,buf,strlen(buf));
        write(STDOUT_FILENO,buf,strlen(buf));
        sleep(1);
    }
    retuen 0;
}

r_fifo.c

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>
#include<funt1.h>
#define BUFSIZE 1024
int main(int argc,char* argc[])
{
    char buf[BUFSIZE];
    int rret;
    int fd = open("myfifo",O_RDONLY);
    while(1)
    {
        rret = read(fd,buf,BUFSIZE);
        write(STDOUT_FILENO,buf,rret);
    }
    retuen 0;
}
4、mmap

​ 存储映射 I/O 使一个磁盘文件与存储空间中的一个缓冲区想映射。越是当从缓冲区中取数据,就相当于读文件中的相应字节。以此类推,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可使用地址(指针)完成 I/O 操作,对文件的操作就可以改为对内存的操作。

​ 使用这种方法,首先应通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过mmap函数来实现。

img

​ mmap 将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是多有页的大小之和,最后一个页不被使用的空间将会清零。

​ 函数原型

<sys/mman.h>
void *mmap(void *addr,size_t length,int prot,int flags,int fd,off_t offset);
int munmap(void *addr,size_t length);
1、内存映射的步骤
  • 用 open 系统调用打开文件,并返回描述符 fd。
  • 用 mmap 建立内存映射,并返回映射首地址指针 addr。
  • 对映射(文件)进行各种操作,显示(printf),修改(sprintf)。
  • 用 munmap(void *addr,size_t length) 关闭内存映射。
  • 用 close 系统调用关闭文件 fd。
2、主要功能

​ 该函数主要用途有三个:

  • 将一个普通文件映射到内存中,通常在需要对文件进行频繁读写时使用,这样使用内存读写取代 I/O 读写,以获得较高的性能。
  • 将特殊文件进行匿名内存映射,可以为关联进程提供共享内存空间。
  • 为无关联的进程提供贡献内存空间,一般也是将一个普通文件映射到内存中。
3、参数及返回值
void *mmap(void *addr,size_t length,int prot,int flags,int fd,off_t offset);
参数
  • 参数 addr:指向欲映射的内存起始地址,通常设为 NULL,代表让系统自动选定地址,映射成功后返回该地址。

  • 参数 length:映射区的长度。

  • 参数 prot:映射区域的保护方式。可以为以下几种方式的组合:

    1. PROT_READ 映射区域可被读取
    2. PROT_WRITE 映射区域可被写入
  • 参数 flags:影响映射区域的各种特性。在调用 mmap() 时必须要指定 MAP_SHARED 或 MAP_PRIVATE 。

    1. MAP_SHARED 对映射区域的写入数据会赋值回文件内,而且允许其他映射该文件的进程共享。
    2. MAP_PRIVATE 对映射区域的写入操作会产生一个映射文件的复制,即私人的 “写入时复制”(copy on write) 对此区域作的任何修改都不会写会原来的文件内容。
    3. MAP_ANONYMOUS 建立匿名映射。此时会忽略参数 fd,不涉及文件,而且映射区域无法和其他进程共享。
  • 参数 fd :要映射到内存中的文件描述符。如果使用匿名内存映射时,即 flags 中设置了 MAP_ANONYMOUS ,fd设为 -1 。有些系统不支持匿名内存映射,则可以使用 fopen 打开 /dev/zero 文件,然后对该文件进行映射,可以同样达到匿名内存映射的效果。

  • 参数 offset:文件映射的偏移量,通常设置为 0,代表从文件最前方开始对应,offset 必须是分页大小的整数倍。

返回值

​ 若映射成功则返回映射区的内存起始地址,否则返回 MAP_FAILED(-1),错误原因存于 errno 中。

4、系统调用 mmap() 用于共享内存的两种方式

1、使用普通文件提供的内存映射

​ 适用于任何进程之间。此时,需要打开或创建一个文件,然后再调用 mmap()

​ 典型调用代码如下:

fd = open(name,flag,mode);if(fd<0){}
ptr = mmap(NULL.len.PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);

​ 通过 mmap() 实现共享内存的通信方式有许多特点和要注意的地方。

2、使用特殊文件提供你们内存映射

​ 适用于具有亲缘关系的进程之间。由于父子进程特殊的亲缘关系,在父进程中先调用 mmap(),然后调用 fork()。那么在调用 fork() 之后,子进程继承父进程匿名映射后的地址空间,同样也继承 mmap() 返回的地址,这样,父子进程就可以通过映射区域进行通信了。注意,这里不是一般的继承关系。一般来说,子进程单独维护从父进程继承下来的一些变量。而 mmap() 返回的地址,却由父子进程共同维护。对于具有亲缘关系的进程实现共享内存最好的方式应该是采用匿名内存映射的方式。此时,不必指定具体的文件,只要设置相应的标志即可

5、mmap 注意事项
思考
  1. 可以 open 的时候 O_CREAT 一个新文件来创建映射区么?
  2. 如果 open 时 O_RDONLY, mmap 时PROT 参数指定 PROT_READ|PROT_WRITE 会怎样?
  3. 文件描述符先关闭,对 mmap 映射有没有影响?
  4. 如果文件偏移量为 100 会怎样?
  5. 对 ptr 越界操作会怎样?
  6. 如果 prt++,munmap 可否成功?
  7. mmap 什么情况下回调用失败?
  8. 如果不检测 mmap 的返回值,会怎样?
总结
  1. 创建映射区的过程中,隐含着一次对映射文件的读操作。
  2. 当 MAP_SHARED 时,要求:映射区的权限应 <= 文件打开时的权限(处于对映射区的保护)。而 MAP_PRIVATE 则无所谓,因为 mmap 中的权限是对应内存的限制。
  3. 映射区的释放与文件关闭无关,只要映射建立成功,文件可以立即关闭。
  4. 特别注意,当映射文件大小为 0 时,不能创建映射区。所以:用于映射的文件必须要有实际大小!mmap 使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。如,400字节大小的文件,在建立映射区时 offset 4096 字节,则会报出总线错误。
  5. munmap 传入的地址一定是 mmap 的返回地址。坚决杜绝指针 ++ 操作。
  6. 如果文件偏移量必须为 4K 的整数倍
  7. mmap 创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。

线程基础

1、线程概念

1、什么是线程

LWP:light weight process 轻量级的进程,本质仍是进程(在 Linux 环境下)

进程:独立地址空间,拥有 PCB

线程:有独立的 PCB,但没有独立的地址空间(共享)

img

区别:在于是否共享地址空间。独居(进程);合租(线程)

Linux 下:线程:最小的执行单位

​ 进程:最小分配资源单位可看成是只有一个线程的进程。

2、Linux 内核线程实现原理

​ 类 Unix 系统中,早期是没有线程概念的,80年代才引入,借助进程实现出了线程的概念。因此在类系统中,进程和线程关系密切。

1. 轻量级进程(light-weight process),也有 PCB,创建线程使用的底层函数和进程一样,都是clone
2. 从内核里看进程是一样的,都有各自不同的 PCB,但是线程的 PCB 中指向内存资源的三级页表是相同的
3. 进程可以蜕变成线程
4. 线程可看做寄存器和栈的集合。
5. 在 Linux下,线程是最小的执行单位;进程是最小的分配资源单位。

img

​ 对于进程来说,相同的地址(同一个虚拟地址)在不同的进程中,反复使用而不冲突。原因是他们虽然虚拟地址一样,但,页目录,页表,物理页面各不相同。相同的虚拟地址,映射到不同的物理页面内存单元,最终访问不同的物理页面。

​ 但,线程不同!两个线程具有各自独立的 PCB,但共享桶一个页目录,也就共享同一个页表和物理页面。所以两个 PCB 共享一个地址空间。

​ 实际上,无论是创建进程的 fork,还是创建线程的 pthread_create,底层实现都是调用同一个内核函数 clone。

​ 如果复制对方的地址空间,那么就产出一个 “进程”;如果共享对方的地址空间,就产生一个 “线程”。

​ 因此:Linux内核是不区分进程和线程的。只在用户层面上进行区分。所以,线程所有操作函数 pthread_* 是库函数,而非系统调用。

2、线程共享资源

  1. 文件描述符表

  2. 每种信号的处理方式

  3. 当前工作目录

  4. 用户ID和组ID

  5. 内存地址空间(.text/.data/.bass/heap/共享库)

​ 注:bss段通常是指用来存放程序中为初始化的全局变量的一块内存区域
​ data段:用于存放在编译阶段(而非运行时)就能确定的数据,可读可写。也是通常所说的静态存储区,赋了初值的全局变量,常量和静态变量都存放在这个域。

3、线程非共享资源

  1. 线程id
  2. 处理器现场和栈指针(内核栈)
  3. 独立的栈空间(用户空间栈)
  4. errno变量
  5. 信号屏蔽字
  6. 调度优先级

4、线程优、缺点

优点

  1. 提高程序并发性
  2. 开销小
  3. 数据通信,共享数据方便

缺点:

  1. 库函数,不稳定
  2. 调试,编写困难,gdb不支持
  3. 对信号支持不好

优点相对突出,确定均不是硬伤。Linux 下由于实现方法导致进程,线程差别不是很大。

5、线程控制原语

pthread_self函数

​ 作用:获取线程ID。其作用对应进程中 getpid() 函数。

pthread_t pthread_self(void);
/*
返回值:
成功:0
失败:无
*/

​ 线程ID:pthread_t 类型,本质:在 Linux 下无符号整数 (%lu),其他系统中可能是结构体实现。

​ 线程ID是进程内部识别标志。(两个进程间,线程ID允许相同)

​ 注意:不应使用全局变量 pthread_t tid 在子线程中通过 pthread_create 传出参数来获取线程ID,而应使用 pthread_self。

pthread_create 函数

​ 作用:创建一个新线程。其作用对应进程中 fork() 函数

int pthread_create(pthread_t *thread,const pthread_attr *attr,void *(*start_routine)(void*),void *arg);
/*
返回值:成功:0;失败:错误号
参数:
pthread_t:当前 Linux 中可理解为: typedef unsigned longint pthread_t;
参数1:传出参数,保存系统使用线程默认属性。若想使用具体属性也可以修改该参数。
参数2:通常传 NULL,表示使用线程默认属性。若想使用具体属性也可以修改该参数。
参数3:函数指针,指向线程主函数(线程体),该函数运行结束,则线程结束。
参数4:线程主函数执行期间所使用的参数。
*/

​ 在一个线程中调用 pthread_create() 创建新的线程后,当前线程 pthread_create() 返回继续往下执行,而新的线程所执行的代码由我们传给 pthread_create 的函数指针 start_routine 决定。start_routine 函数接收一个参数,是通过 pthread_create 的 arg 参数传递给它的,该参数的类型为 void*,这个指针的含义同样由调用者自己定义。start_routine 返回时,这个线程类型就退户了,其他线程可以调用 pthread_join 得到 start_routine 的返回值,类似于父进程调用 wait 得到子进程的退出状态,稍后详细介绍 pthread_join。

​ pthread_create 成功返回后,新创建的线程的id被填写到thread 参数所指向的内存单元。我们知道进程 id 的类型是 pid_t,每个进程的 id 在整个系统中是唯一的,调用 getpid() 可以获得当前进程的 id,是一个正整数值。线程 id 的类型是 thread_t,它可能是一个整数值,也可能是一个结构体,也可能是一个地址,所以不能简单地当成整数用 printf 打印,调用 pthread_self() 可以获得当前线程的 id。

​ attr 参数表示线程属性,本节不深入讨论线程属性,所有代码例子都传NULL 给attr 参数,表示线程属性取缺省值。

pthread_exit 函数

将单个线程退出

void pthread_exit(void *retval);
//参数:retval 表示线程退出状态,通常传 NULL

​ 思考:使用 exit 将指定线程退出,可以吗?

​ 结论:线程中,禁止使用 exit 函数,会导致进程内所有线程全部退出。

​ 在不添加 sleep 控制输出顺序地情况下。pthread_create 在循环中,几乎瞬间创建 5 个线程,但只有第 1 个线程有机会输出(或者第2个也有,也可能没有,取决于内核调度)如果第 3 个线程执行了 exit,将整个进程退出了,所以全部线程退出了。

​ 所以,多线程环境中,应尽量少用,或者不使用 exit 函数,取而代之使用 pthread_exit 函数,将单个线程退出。任何线程里 exit 导致进程腿很粗,但其他线程未工作结束,主控线程退出时不能 return 或 exit。

​ 另注意,pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的,不能在线程函数的线上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_join 函数

作用:阻塞等待线程提出,获取线程退出状态,对应进程中 waitpid() 函数。

int pthread_join(pthread_t thread,void **retval);
/*
返回:
成功:0
失败:错误号
参数:thread:线程 ID(注意:不是指针)
参数:retval:存储线程结束状态。
*/

对比记忆:

进程中:main 返回值,exit 参数—>int; 等待子进程结束 wait 函数参数—> int*

线程中:线程主函数返回值,pthread_exit—>void*;等待线程结束 pthread_join 函数参数—>void**

pthread_detach 函数

作用:实现线程分离

int pthread_detach(pthread_t thread);
/*
返回:
成功:0
失败:错误号
*/

​ 线程分离状态:指定该状态,线程主动与主控制线程断开关系。线程结束后,其推出状态不由其他线程获取,而直接字节自动释放。网络,多线程服务器常用。

​ 进程若有该机制,将不会产生僵尸进程。僵尸进程的产生主要由于进程死后,大部分资源被释放,一旦残留资源仍存在于系统中,导致内核认为该进程仍存在。

​ 也可以使用 pthread_create 参数参 2 (线程属性) 来设置线程分离。

pthread_detach 函数

作用:杀死(取消)线程,对应进程中 kill() 函数

int pthread_cancel(pthread_t thread);
/*
返回:
成功:0;
失败:错误号;
*/

​ 注意:线程的取消并不是实时的,而有一定的延时。需要等待线程到达某个取消点(检查点)。

​ 类似于玩游戏存档,必须到达指定的场所(存档点)才能存储进度。杀死线程也不是立刻就能完成,必须要到达取消点。

​ 取消点:是进程检查是否被取消,并按请求动作的一个位置。通常是一些系统调用 creat,open,close,read,write… 执行命令 man 7 pthreads 可以查看具备这些取消点的系统调用列表。

​ 可粗略认为一个系统调用 (进入内核)即为一个取消点。如进程中没有取消点,可以通过调用 pthread_testcancel 函数自信设置一个取消点。

线程同步

前言

​ 学进程前没学习的重点应该房子进程间通信,而学习线程时,重点就应该是线程同步来。想想为什么?fork 创建子进程之后,子进程有自己的独立地址空间和 PCB,想和父进程或其他进程通信,就需要各种通信方式,例如无名管道(管道),有名管道(命名管道)、信号、消息队列、共享内存等;而 pthread_create 创建子线程之后,子线程没有独立的地址空间,大部分数据都是共享的,如果同时访问数据,就会造成混乱,所以要控制,就是线程同步了。

1、什么是线程同步

​ 同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。这里的同步千万不要理解成那个同事进行,应是值协同、协助、互相配合。线程同步是指多线程通过特定的设置(如互斥量,条件变量等)来控制线程之间的执行顺序(即所谓的同步)也可以说是在线程之间通过同步建立起执行顺序地关系,如果没有同步,那线程之间是各自运行各自的!

​ 线程互斥是指对于共享的进程系统资源,在各单个线程访问的排他性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,知道占用资源着释放该资源。

2、线程同步的方式

1、互斥锁(互斥量)
1、介绍

img

​ Linux 中提供一把互斥锁 mutex (也称为互斥量)

​ 每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。

​ 资源还是共享的,线程间也还是竞争的,但通过 “锁” 就将资源的访问变成互斥操作,而后与时间有关的错误也不会再生产了。

2、相关函数
pthread_mutex_init
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t *mutexattr);
/*
功能:初始化一个互斥锁(互斥量)
参1:传出参数,调用时应传 &mutex
参2:互斥量属性。是一个传入参数,通常传 BULL,选用默认属性(线程间共享)。
*/
pthread_mutex_lock
int pthread_mutex_lock(ptpthread_mutex_t *mutex);
/*
功能:加锁
如果加锁不成功,线程阻塞,阻塞到持有该互斥量的其他线程解锁为止
*/
pthread_mutex_trylock
int pthread_mutex_trylock(pthread_mutex_t *mutex);
/*
功能:尝试加锁
trylock 加锁失败直接返回错误号(如:EBUSY),不阻塞
*/
pthread_mutex_unlock
int pthread_mutex_unlock(pthread_mutex_t *mutex);
/*
功能:解锁
unlock 主动解锁函数,同时将阻塞在该锁上的所有进程全部唤醒,值域哪个线程先被唤醒,取决于优先级。调度。默认:先阻塞、后唤醒
*/
pthread_mutex_destory
int pthread_mutex_destory(pthread_mutex_t *mutex);
/*
功能:销毁一个互斥锁
*/

​ 以上5 个函数的返回值都是:成功返回 0,失败返回错误号。

​ pthread_mutex_t 类型,其本质就是一个结构体。为简化理解,应用时可忽略其实现细节,简单当成整数看待。

​ pthread_mutex_t mutex:变量 mutex 只有两种取值 1/0

​ 在访问共享资源前加锁,访问结束后立即解锁。锁的 “粒度”应越小越好。

2、读写锁
1、介绍

​ 与互斥锁类似,但读写锁允许更高的并行性。其特性为:写独占,读共享,写锁优先级高于读锁

​ 特别强调:读写锁只有一把,但其具备两种状态:

  • 读模式下加锁状态(读锁)
  • 写模式下加锁状态(写锁)
2、读写锁特性
  1. 读写锁是 “写模式加锁” 时,解锁前,所有对该锁加锁的线程会被阻塞。

  2. 读写锁是 “读模式加锁”时,如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞。

  3. 如果一个读线程和一个写线程同时申请读写锁,写线程优先加锁。

    读写锁也叫共享-独占锁。当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独立模式锁住的。写独立,读共享

3、读写锁的场景分析

1、场景一:均是读请求

​ 当下面 4 个线程请求读写锁时,不管是否是同时,都能成功上锁访问数据。

img

2、场景二:T1,T2线程同时上锁,T3,T4在后面请求。

​ 由于T1,T2是同时请求上锁,但写请求优先级高,所以T2先上锁,其它线程阻塞。T2 线程处理完后 T1,T3,T4 均能上锁,上锁的顺序看 CPU 先处理哪个请求,不需要考虑顺序。

img

3、场景3:T1,T2在以读方式上锁时,T4,T4以写方式请求。

因为T1,T2已经上锁,所以T3,T4会阻塞等待,即使你是以写方式请求。

写请求优先级高是针对于没有上锁且是同时请求的情况下。

注意:当一把锁被写方式锁住时,读方式请求先到,然后再以写方式请求,由于锁被锁住,所以这种也认为是同时,写方式请求将被放在读方式请求之前。

当一把锁被读方式锁住时不需要考虑,因为再以读方式先请求的话会共享,写方式再请求因读方式请求已经共享,所以实际剩下自己,已经无对比可言。

img

4、相关函数
pthread_rwlock_init
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);
pthread_rwlock_rdlock 和 pthread_rwlock_tryrdlock
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock (pthread_rwlock_t *rwlock);
pthread_rwlock_wrlock 和 pthread_rwlock_trywrlock
int pthread_rwlock_wrlock (pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
pthread_rwlock_unlock
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
pthread_rwlock_destory
int pthread_rwlock_destory(pthread_rwlock_t *rwlock);
3、条件变量
1、介绍

​ 条件变量本身不是锁!但他也可以造成线程阻塞。通常与互斥锁配合使用。给多线程提供一个会合的场所。

2、相关函数
pthread_cond_init
int pthread_cond_init(pthread_cond_t *cond,pthread_condattr_t *cond_attr);
/*
功能:初始化一个条件变量
参1:要初始化的条件变量
参2:attr 表条件变量属性,通常为默认值,传 NULL 即可
*/
pthread_cond_destory
int pthread_cond_destory(pthread_cond_t *cond);
/*
功能:销毁一个条件变量
*/
pthread_cond_wait
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
/*
功能:阻塞等待一个条件变量
函数作用:
	1 2 两步为一个原子操作
1. 阻塞等待条件变量 cond(参1) 满足
2. 释放已掌握的互斥锁(接触互斥量)相当于pthread_mutex_unlock(&mutex)
3. 当被唤醒,pthread_cond_wait 函数返回时,解除阻塞并重新申请获取互斥锁 pthread_mutex_lock(&mutex);
*/
pthread_cond_timedwait
int pthread_cond_timedwait(pthread_cond_t *cond,pthread_mutex_t *mutex,const struct timespec *abstime);
//功能:限时等待一个条件变量
//参3:查看 struct timespec 结构体
struct timespec
{
    time_t tv_sec;	// 秒
    long tv_nsec	//纳秒
}
/*
形参 abstime:绝对时间
*/
pthread_cond_signal
int pthread_cond_signal(pthread_cond_t *cond);
/*
功能:唤醒一个阻塞在条件变量上的线程
*/
pthread_cond_broadcast
int pthread_cond_broadcast(pthread_cond_t *cond);
/*
功能:唤醒全部阻塞在条件变量上的线程
*/

线程安全

1、什么是线程安全

​ 在拥有共享数据多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

2、如何保证线程安全?

  • 共享的资源加把,保证每个资源变量每时每刻至多被一个线程占用。

  • 让线程也拥有资源,不用去共享进程中的资源。如: 使用threadlocal可以为每个线程的维护一个私有的本地变量。

3、什么是单例模式?

单例模式指在整个系统生命周期里,保证一个类只能产生一个实例,确保该类的唯一性

1、单例模式分类

单例模式可以分为懒汉式饿汉式,两者之间的区别在于创建实例的时间不同

  • 懒汉式:指系统运行中,实例并不存在,只有当需要使用该实例时,才会去创建并使用实例。(这种方式要考虑线程安全)
  • 饿汉式:指系统一运行,就初始化创建实例,当需要时,直接调用即可。(本身就线程安全,没有多线程的问题)
2、单例类特点
  • 构造函数和析构函数为private类型,目的禁止外部构造和析构
  • 拷贝构造和赋值构造函数为private类型,目的是禁止外部拷贝和赋值,确保实例的唯一性
  • 类里有个获取实例的静态函数,可以全局访问
3、普通懒汉式单例 ( 线程不安全 )
///  普通懒汉式实现 -- 线程不安全 //
#include <iostream> // std::cout
#include <mutex>    // std::mutex
#include <pthread.h> // pthread_create

class SingleInstance
{

public:
    // 获取单例对象
    static SingleInstance *GetInstance();

    // 释放单例,进程退出时调用
    static void deleteInstance();
	
	// 打印单例地址
    void Print();

private:
	// 将其构造和析构成为私有的, 禁止外部构造和析构
    SingleInstance();
    ~SingleInstance();

    // 将其拷贝构造和赋值构造成为私有函数, 禁止外部拷贝和赋值
    SingleInstance(const SingleInstance &signal);
    const SingleInstance &operator=(const SingleInstance &signal);

private:
    // 唯一单例对象指针
    static SingleInstance *m_SingleInstance;
};

//初始化静态成员变量
SingleInstance *SingleInstance::m_SingleInstance = NULL;

SingleInstance* SingleInstance::GetInstance()
{

	if (m_SingleInstance == NULL)
	{
		m_SingleInstance = new (std::nothrow) SingleInstance;  // 没有加锁是线程不安全的,当线程并发时会创建多个实例
	}

    return m_SingleInstance;
}

void SingleInstance::deleteInstance()
{
    if (m_SingleInstance)
    {
        delete m_SingleInstance;
        m_SingleInstance = NULL;
    }
}

void SingleInstance::Print()
{
	std::cout << "我的实例内存地址是:" << this << std::endl;
}

SingleInstance::SingleInstance()
{
    std::cout << "构造函数" << std::endl;
}

SingleInstance::~SingleInstance()
{
    std::cout << "析构函数" << std::endl;
}
///  普通懒汉式实现 -- 线程不安全  //

// 线程函数
void *PrintHello(void *threadid)
{
    // 主线程与子线程分离,两者相互不干涉,子线程结束同时子线程的资源自动回收
    pthread_detach(pthread_self());

    // 对传入的参数进行强制类型转换,由无类型指针变为整形数指针,然后再读取
    int tid = *((int *)threadid);

    std::cout << "Hi, 我是线程 ID:[" << tid << "]" << std::endl;

    // 打印实例地址
    SingleInstance::GetInstance()->Print();

    pthread_exit(NULL);
}

#define NUM_THREADS 5 // 线程个数

int main(void)
{
    pthread_t threads[NUM_THREADS] = {0};
    int indexes[NUM_THREADS] = {0}; // 用数组来保存i的值

    int ret = 0;
    int i = 0;

    std::cout << "main() : 开始 ... " << std::endl;

    for (i = 0; i < NUM_THREADS; i++)
    {
        std::cout << "main() : 创建线程:[" << i << "]" << std::endl;
        
		indexes[i] = i; //先保存i的值
		
        // 传入的时候必须强制转换为void* 类型,即无类型指针
        ret = pthread_create(&threads[i], NULL, PrintHello, (void *)&(indexes[i]));
        if (ret)
        {
            std::cout << "Error:无法创建线程," << ret << std::endl;
            exit(-1);
        }
    }

    // 手动释放单实例的资源
    SingleInstance::deleteInstance();
    std::cout << "main() : 结束! " << std::endl;
	
    return 0;
}
普通懒汉式单例运行结果

从运行结果可知,单例构造函数创建了两个,内存地址分别为0x7f3c980008c00x7f3c900008c0,所以普通懒汉式单例只适合单进程不适合多线程,因为是线程不安全的。

img

4、加锁的懒汉式单例 ( 线程安全 )
///  加锁的懒汉式实现  //
class SingleInstance
{

public:
    // 获取单实例对象
    static SingleInstance *&GetInstance();

    //释放单实例,进程退出时调用
    static void deleteInstance();
	
    // 打印实例地址
    void Print();

private:
    // 将其构造和析构成为私有的, 禁止外部构造和析构
    SingleInstance();
    ~SingleInstance();

    // 将其拷贝构造和赋值构造成为私有函数, 禁止外部拷贝和赋值
    SingleInstance(const SingleInstance &signal);
    const SingleInstance &operator=(const SingleInstance &signal);

private:
    // 唯一单实例对象指针
    static SingleInstance *m_SingleInstance;
    static std::mutex m_Mutex;
};

//初始化静态成员变量
SingleInstance *SingleInstance::m_SingleInstance = NULL;
std::mutex SingleInstance::m_Mutex;

SingleInstance *&SingleInstance::GetInstance()
{

    //  这里使用了两个 if判断语句的技术称为双检锁;好处是,只有判断指针为空的时候才加锁,
    //  避免每次调用 GetInstance的方法都加锁,锁的开销毕竟还是有点大的。
    if (m_SingleInstance == NULL) 
    {
        std::unique_lock<std::mutex> lock(m_Mutex); // 加锁
        if (m_SingleInstance == NULL)
        {
            m_SingleInstance = new (std::nothrow) SingleInstance;
        }
    }

    return m_SingleInstance;
}

void SingleInstance::deleteInstance()
{
    std::unique_lock<std::mutex> lock(m_Mutex); // 加锁
    if (m_SingleInstance)
    {
        delete m_SingleInstance;
        m_SingleInstance = NULL;
    }
}

void SingleInstance::Print()
{
	std::cout << "我的实例内存地址是:" << this << std::endl;
}

SingleInstance::SingleInstance()
{
    std::cout << "构造函数" << std::endl;
}

SingleInstance::~SingleInstance()
{
    std::cout << "析构函数" << std::endl;
}
///  加锁的懒汉式实现  //
加锁的懒汉式单例的运行结果

从运行结果可知,只创建了一个实例,内存地址是0x7f28b00008c0,所以加了互斥锁的普通懒汉式是线程安全的

img

5、内部静态变量的懒汉单例(C++11 线程安全)
///  内部静态变量的懒汉实现  //
class Single
{

public:
    // 获取单实例对象
    static Single &GetInstance();
	
	// 打印实例地址
    void Print();

private:
    // 禁止外部构造
    Single();

    // 禁止外部析构
    ~Single();

    // 禁止外部复制构造
    Single(const Single &signal);

    // 禁止外部赋值操作
    const Single &operator=(const Single &signal);
};

Single &Single::GetInstance()
{
    // 局部静态特性的方式实现单实例
    static Single signal;
    return signal;
}

void Single::Print()
{
	std::cout << "我的实例内存地址是:" << this << std::endl;
}

Single::Single()
{
    std::cout << "构造函数" << std::endl;
}

Single::~Single()
{
    std::cout << "析构函数" << std::endl;
}
///  内部静态变量的懒汉实现  //
内部静态变量的懒汉单例的运行结果

-std=c++0x编译是使用了C++11的特性,在C++11内部静态变量的方式里是线程安全的,只创建了一次实例,内存地址是0x6016e8,这个方式非常推荐,实现的代码最少!

[root@lincoding singleInstall]#g++  SingleInstance.cpp -o SingleInstance -lpthread -std=c++0x

img

6、饿汉式单例 (本身就线程安全)
// 饿汉实现 /
class Singleton
{
public:
    // 获取单实例
    static Singleton* GetInstance();

    // 释放单实例,进程退出时调用
    static void deleteInstance();
    
    // 打印实例地址
    void Print();

private:
    // 将其构造和析构成为私有的, 禁止外部构造和析构
    Singleton();
    ~Singleton();

    // 将其拷贝构造和赋值构造成为私有函数, 禁止外部拷贝和赋值
    Singleton(const Singleton &signal);
    const Singleton &operator=(const Singleton &signal);

private:
    // 唯一单实例对象指针
    static Singleton *g_pSingleton;
};

// 代码一运行就初始化创建实例 ,本身就线程安全
Singleton* Singleton::g_pSingleton = new (std::nothrow) Singleton;

Singleton* Singleton::GetInstance()
{
    return g_pSingleton;
}

void Singleton::deleteInstance()
{
    if (g_pSingleton)
    {
        delete g_pSingleton;
        g_pSingleton = NULL;
    }
}

void Singleton::Print()
{
    std::cout << "我的实例内存地址是:" << this << std::endl;
}

Singleton::Singleton()
{
    std::cout << "构造函数" << std::endl;
}

Singleton::~Singleton()
{
    std::cout << "析构函数" << std::endl;
}
// 饿汉实现 /
饿汉式单例的运行结果

从运行结果可知,饿汉式在程序一开始就构造函数初始化了,所以本身就线程安全的
img


特点与选择

  • 懒汉式是以时间换空间,适应于访问量较时;推荐使用内部静态变量的懒汉单例,代码量少
  • 饿汉式是以空间换时间,适应于访问量较时,或者线程比较多的的情况

生产者消费者模型

​ 线程同步典型的案例即生产者消费者模型,而借助条件变量来实现这一模型,是比较常见的一种。假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。两个线程同时操作一个共享资源(一般称之为汇聚),生产者向其中添加产品,消费者从中消费掉产品。

1、条件变量的优点

​ 相比较 mutex 而言,条件变量可以减少竞争。

​ 如直接使用 mutex,除了生产者,消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果汇聚(链表) 中没有数据,消费者之间竞争互斥锁是无意义的。有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的竞争。提高了程序效率。

2、实现

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<ynistd.h>
#include<fcntl.h>
#include<pthread.h>
struct food
{
	int nu;
    struct food* next;
};
struct food* head = NULL;
pthread_mutex_t mutex;
pthread_cond_t cond;
void sys_err(char* str,int ernu)
{
    printf("%s:%s",str,strerror(ernu));
    exit(1);
}
void* producer(void* arg)
{
    int i = 0;
    while(1)
    {
        struct food* pro = (struct food*)malloc(sizeof(struct food));
        pro->nu = i++;
        printf("---producer a food -----,nu = %d\n",pro->nu);
        pthread_mutex_lock(&mutex);
        pro->next = head ;
        head = po;
        pthread_mutex_unlock(&mutex);
        pthread_cond_broadcast(&cond);
        usleep(rand()%10000);
    }
    return NULL;
}
void* consumer(void* arg)
{
    while(1)
    {
        struct food* con;
        pthread_mutex_lock(&mutex);
        while(head==NULL)
        {
            pthread_cond_wait(&cond,&mutex);
        }
        con = head;
        head = head->next;
        pthread_mutex_unlock(&mutex);
        printf("+++++++++ consumer a food ++++++ nu = %d\n",con->nu);
        free(con);
        usleep(rand()%10000);
    }
    return NULL;
}

int main(int argc,char* argv[])
{
    srand(time(NULL));
    pthread_mutex_init(&mutex,NULL);
    pthread_mutex_init(&cond,NULL);
    pthread_t tid;
    pthread_create(&tid,NULL,producer,NULL);
    pthread_detach(tid);
    pthread_create(&tid,NULL,consumer,NULL);
    pthread_detach(tid);
    pthread_create(&tid,NULL,consumer,NULL);
    pthread_detach(tid);
    pthread_create(&tid,NULL,consumer,NULL);
    pthread_detach(tid);
    pthread_create(&tid,NULL,consumer,NULL);
    pthread_detach(tid);
    while(1);
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
    return 0;    
}

进程与线程区别

职能

进程:资源分配的最小单位

线程:资源调度的最小单位

地址空间

进程有自己的地址空间

线程几乎没有

通信便利性

进程间通信较麻烦

线程之间通信更方便

健壮性

多进程程序更健壮

多线程程序只要一个线程死掉,整个进程也死掉了

多进程和多线程区别

维度多进程多线程总结
数据共享,同步数据是分开的:共享复杂,需要IPC;同步简单多线程共享进程数据:共享简单,同步复杂各有优势
内存,CPU占用内存多,切换复杂,CPU利用率低占用内存少,切换简单,CPU利用率高线程占优
创建/销毁/切换复杂,速度慢简单,速度快线程占优
编程调试简单复杂进程占优
可靠性进程间不会互相影响一个线程挂掉导致整个线程挂掉进程占优
分布式适应于多核,多机分布;如果一台机器不够,扩展到多台机器比较简单适应于多核分布进程占优

死锁

1、什么是死锁?

​ 多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。

img

2、产生死锁的原因?

  • 竞争资源

  • 进程间推进顺序非法

3、产生死锁的必要条件

互斥性

​ 进程要求对所分配的资源进行排他性控制,即在一段时间内某资源仅为一进城所占用。

请求和保持条件

​ 当进程因请求资源而阻塞时,对已获得的资源保持不放

不剥夺条件

​ 进程已获得的资源在未使用完之前,不能剥夺。只能在使用完时由自己释放。

环路等待

​ 在发生死锁时,必然存在一个进程——资源的环形链

死锁不是一种锁,而是在使用的期间产生的一种现象

如果发生以下两种情况可能产生死锁:

  • 线程试图对同一互斥量 A 加锁两次
  • 线程 1 拥有 A 锁,请求获得 B 锁;线程 2 拥有 B 锁,请求获得 A 锁

4、解决死锁的基本方法

预防死锁
避免死锁
检测死锁
解除死锁
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值