# 操作系统基础

<font style="background: MediumSpringGreen">
<font style="background:yellow">
1
2

# 目录

# Linux和unix网络编程-常用头文件

1)#include<unistd.h>		//提供通用的文件、目录、程序及进程操作的函数
代表系统调用函数
fork()
_exit()

记忆方法:<unistd.h>表示的是unix和std.h表示Unix的标准头文件 
2#include <sys/types.h>	//数据类型定义
#include <sys/wait.h>	//提供进程等待的函数
代表系统调用函数
wait();
waitpid()
1
2
3
4
5
6
7
8
9
10
11
12

进程控制块PCB

# 2.概念辨析

# 2.1.什么是系统资源

1)进程控制块(PCB)是重点知识
2)常用环境变量
环境变量的函数,了解就好了。
3)进程控制原语
比如,创建进程,对进程进行操作等,回收进程等的一些相关函数
1
2
3
4
5
  • 系统资源:主要是指
    • CPU
    • 内存
    • 你所打开的文件个数
    • 你所使用的设备
    • 你所使用的....(所以,磁盘不算系统资源!)
  • 我们现在说的32位的还是64位的计算机,简而言之就是相对寄存器而言的
1、DOS系统就是典型的『单道程序设计模型』也就是CPU在同一个时刻只能处理一个任务,比如
2、多道程序设计
CPU时间片到了,要把使用权利收回来的
强制手段有个概念叫做“时钟中断”————硬件手段
    人的眼睛反应是ms级别的。
    所以在人类的眼睛看来,好几个程序是“并行“执行的
1
2
3
4
5
6

# 2.2.MMU(内存管理单元)

  • MMU(Memory Management Unit,内存管理单元)

day01.01

  • 1、译码器:时间是就是解析这条指令干嘛的,我需要哪些寄存器来配合完成这条指令的功能。
  • 2、ALU:ALU实际上只会两种运算,
    • 一种是加法
    • 一种是左移
    • 大家熟悉的减法,除法,取模运算,都是用这两种运算模拟出来的(注意:所以,这个也暗示我们,可以用加法和左移来优化我们的程序吗?类似于,在一定范围内,左移,相当于乘2——比如,求薛定谔方程?所以,我们是不是可以疯狂的想象一下,或许,虽然从数学上算薛定谔方程很难,但是可能用ALU可以直接映射到某种具有新活力的数学运算?)
  • 3、MMU
    • 它主要是来完成,虚拟内存物理内存的对应的。MMU位于CPU内部!!
    • 1、虚拟内存与物理内存的映射
    • 2、设置修改内存访问级别,内存访问级别的设置和修改.
    • 下面的图很重要!

day01.02

  • 虚拟内存中3G-4G叫做:内核区(或者叫内核空间)在内核区里面,有我们要讲的比较重要的知识点,叫做PCB,进程控制块
  • 0G-3G叫做用户区(用户空间)
程序运行的时候会产生『虚拟内存空间』。执行a.out,就会产生一个进程,产生进程的同时就产生了一个虚拟内存  
1、内核区保留了程序运行的时候,要进入计算机内核中的一些东西。  
2、正是因为有虚拟内存,所以,我们才能在物理内存少于虚拟内存的地方,还能跑起来程序
1
2
3
  • 虚拟地址: 可用的地址空间有4G
  • 虚拟内存是不存在的,实际上还是存到物理内存中去的。MMU就是这样的一个媒介,将虚拟地址『对应』到实际的物理地址上去。MMU帮你完成这个对应。而这样就保证了,我们写程序,只管用虚拟地址。你永远都不会在你的程序当中使用到物理地址。

# 2.对比情况

  • 对于虚拟内存来说,内核空间和用户空间不一样!! 内核空间的访问权限比较大,可以访问你整个内存区域的所有数据 用户空间的访问权限小一些,我只能访问你0-3G中的数据。不能访问内核空间中的数据
  • 那么问题来了,物理内存中,有没有这样的一回事呢???
  • A:是没有的,我就一根内存条。因为内存条整个架构都一样,存储怎么就分出了权限高低??? 这就着落到MMU上了,MMU在完成映射的同时,也给你设置了内存的访问级别(当然,这个访问级别是给CPU设置的
- 正常来说,Intel架构,他所涉及的访问级别有几个?有四个,为0-3,CPU有四种访问级别。3级别最低,访问权限最小,0级别最高
- 但是Linux下使用的时候,我们只使用了CPU的两种级别。一种是3级。一种是0级.显然,内核空间是0级,用户空间是3级
- 比如,printf底层用到了系统调用write,最先是在用户空间是3级,后面要进入内核。如何完成?
  那么要『『调整CPU访问级别,那就是MMU进行调整』』
1
2
3
4

# 3.MMU的地位

MMU的地位和ALU啥的是一样的

PCB进程控制块,是随着你./a.out运行以后,这块虚拟地址空间产生,同时它产生出来的 PCB(进程控制块) 它还有另一个名字,—进程描述符 作用: 描述当前进程,相关信息

物理内存,要是没有虚拟内存那么大,那么是如何完成我们的程序加载运行呢? 你用多大,我加载多大。

强调 假设,32位机器 rodata和text总共2KB大,那么他们加载到物理内存是多大? 4KB 为啥? 因为是按照一个page去进行分配的。 这个,一个page才是MMU划分物理内存的最小单位

  • 问题 假设,我们开始了两个进程 a.out在两个对话框中,开启了2个进程 那么,虚拟地址空间有2份 每个进程的虚拟地址段肯定需要映射, 而且,需要另外开,虽然这两个程序一样!!! 因为:强调,进程彼此是独立的!!! 所以,虽然都叫a.out, 但是所占的进程地址空间是各自独立的,。 所以映射到的物理内存,是不能放到一块的,
  • 注意,内核空间 什么叫内核,是操作系统的核心程序 我们简称为内核。 内核是用来驱使你当前计算机工作的,辅助你程序运行的。 辅助你所有的进程运行的。你就简单,把他看做一个进程就完了, 那么辅助这两个进程的内核,是同一个内核。
  • 重点,重点: 注意点:内核区,也要映射,但是相比用户区,不需要重新开辟新空间 也就是,每个进程的内核区都映射到同一个物理地方。 但是用户区,不是。他映射后,需要重新开辟新的物理空间 缘由:内核一份,不同进程,但是共用同一份内核空间。

问题继续,不是,两个进程中各自的PCB是描述自身进程吗? PCB肯定不一样啊,那还能映射到一块物理内存吗?

但是,竟然是映射到同一块物理内存了,那么你的PCB是怎样不一样的呢? 这个解释,要看MMU是如何把他实现的。(我觉得,可能是MMU把他们映射到一个PCB表?我猜的) PCB

  • 结论: 进程中虚拟空间 PCB是位于内核空间当中,但是两个进程的PCB,不一样,但是他们位于同一块物理内存里面!!! 所以,显然,MMU要和预取器配合,

# 2.3.PCB

# 1.PCB的2个中文名称

  • 一个是**『内核』**的称呼方式:进程控制块
  • 一个是**『操作系统原理』**称呼方式:进程描述符

# 2.PCB的本质是什么?

PCB在我们内存当中存在的形式是以结构体的形式存在的,在Linux中,这个结构体的名字叫做task_struct 难怪,Linux下,进程和线程实现都是task变的 Linux 内核的进程控制块是task_struct结构体。 技巧:找task_struct位于哪 grep –r “task_struct”

头文件经常是放在user目录的

进程状态下,我们一般把初始化和就绪简单合并一下

  • 描述控制终端的信息。
  • 当前工作目录(Current Working Directory)
  • umask掩码

控制终端,信息,比如两个ls分别在两个终端 一个是tty8 一个是tty11

当前工作目录,比如,不同目录下,用ls效果不一样(重点理解) umask保护文件,默认创建,或者修改权限。

文件描述符表, 文件描述符,相当于一个句柄,拿到这个,能够找到这个文件

  • 会话(Session)和进程组。
  • 进程可以使用的资源上限 ( Resource Limit)。

为了方便进程的管理,还有一个概念 进程组:把功能相近,或者功能相似,这样的进程放到一起,组成一个进程组,方便管理。 资源上限,比如,栈溢出,在Linux下,栈是多大? ulimit –a可以查看 打开文件最大上限数

# 2.4.环境变量environ

  • 使用下面代码,获取当前进程的所有环境变量environ
//environ.c
#include <stdio.h>
//二级指针
extern char **environ;
int main(void)
{
    int i;
    for (i = 0; environ[i] != NULL; i++)
    {
        printf("%s\n", environ[i]);
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
  • 环境变量,是指在操作系统中用来指定操作系统运行环境的一些参数。环境变量,如果有多个值,用:号隔开

每个进程在工作的时候,都有属于自己的环境信息

对于shell来说,他默认的环境变量有哪些? PTAH,用来记录当前程序的可执行路径

注意,程序在PATH中是从前往后找,所以,我们要是有新版本的软件,都会往PATH路径前面放。 哨兵是为了防止溢出 环境变量,和我们的命令行参数很像。

  • 从现在开始,学习所有的函数,一定都是从man入手 在man中,如果从原型上能看出意思,就不用看后面的,猜不出来,就找看不出来部分的Description描述
  • 进程控制: fork出来的进程,是当前进程的子进程 注意:fork返回值有2个???(打破了,我们以往编写函数的认识)严谨的说,不是的,:一次函数调用,我由一个进程,变成2个进程。然后,这2个进程,各自对fork做返回。 1.返回子进程的pid_t 进程id 2.返回0 表示调用成功?
  • 注意:其实,不是一次函数调用返回两。 Fork出来的子进程,以往执行过的,不再执行了,直接从子进程的fork开始往后执行。 对父进程,也是从fork开始执行。 所以,父进程的fork返回的是子进程的id, 子进程的fork返回的是0(表示,进程创建成功)
  • 好处: 这样,通过fork的返回值,在后续的代码当中,我就能区分出父进程的逻辑和子进程的逻辑 Fork>0返回值,要是大于0,也就是返回了子进程的id,说明后续代码是父进程的。 如果,fork返回值是0,说明后续代码是子进程的。

# 3.系统调用-进程

# 3.1.进程相关函数

<unistd.h>
fork

<stdlib.h>
exit

1
2
3
4
5
6

# 1)父子进程资源

父,子进程fork之后 子进程会把代码“复制”一份,注意:但是那些已经执行过的的,不会再执行,但是有 .data,.bss也“复制”

真的每fork一个子进程都要将父进程的0-3G地址空间完全拷贝一份,然后在映射至物理内存吗? 早期是的,现在不是的。 当然不是!父子进程间遵循读时共享(物理地址),写时复制的原则。 这样设计,无论子进程执行父进程的逻辑还是执行自己的逻辑都能节省内存开销。

所以,我们无法再全局变量中共享 因为这些个进程的全局变量是独立的,.data

那么,父子进程到底共享什么东西???

  1. 文件描述符(打开文件的结构体) ——那么,可以同时操控同一个文件
  2. mmap建立的映射区 (进程间通信详解) mmap在两个进程之间建立一个映射区,完成进程之间数据传递

特别的,fork之后父进程先执行还是子进程先执行不确定。取决于内核所使用的调度算法。 在内核当中,专门有一个进程专门用来调度进程的。

# 3.2.exec函数族(execute,v.执行)

此外,没有成功才返回值,成功就不返回(没有消息就是好消息) 这个家族的函数的共同特征,都是以exec开头 干嘛的: 相当于运行一个进程的作用,只不过。我可以在程序当中运行一个进程

比如,我们可以进行,让子进程区执行另一个进程,而不是简单的输出啥的 这样就能让子进程执行自己的代码

当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序启动例程开始执行。 启动例程:调用你main函数的那个函数,我们把它称之为启动例程(是用C语言和汇编混合编写的) 将当前进程的.text、.data替换为所要加载的程序的.text、.data,然后让进程从新的.text第一条指令开始执行,但进程ID不变,换核不换壳。

调用exec并不创建新进程,所以调用exec前后该进程的id并未改变 子进程往往要调用一种exec函数以执行另一个程序。

函数...三个点,表示是变参,参数的类型,类数是不固定的

execlp (l表示list列表,path表示环境变量指代) 使用实例

execlp("ls","ls","-l","-a",NULL);//NULL是哨兵
1

可以用这个来加载系统当中的可执行程序

execl 使用实例

execl("/bin/ls","ls","-l","-a",NULL);//NULL是哨兵
1

可以用这个来加载一个,我自定义的程序

execle (e表示environment)

execv (v命令行参数的argv)

char * argv[]={"ls","-l","-a",NULL};//NULL是哨兵
execl("/bin/ls",argv);
1
2

open和dup2可以将

我们编程喜欢这样命名。 strtonum改为str2num kkforcc改为kk4cc

#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd,int newfd);
1
2
3

是完成文件描述符的拷贝的。

#include <unistd.h>
include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    int fd;
	fd = open("ps.out", o_wRONLY|O_CREAT|O_TRUNC, 0644);
    if(fd < o){
        perror("open ps.out error");
        exit(1);
    }
	dup2(fd,STDOUT_FILENO);
	execlp("ps","ps","ax",NULL);
	return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 3.3.2个回收子进程函数

  • 子进程有两种比较重要的状态
  • 父进程有义务将子进程回收。
ps的时候
[zoom]<defunct>
这样的是僵尸进程,用中括号框起来了,比如,生活中
买书的时候,要是人名是用中括号框起来,那就是它离去了。
defunct代表死亡
1
2
3
4
5

有2个函数能回收子进程

# 1、wait

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

  • ① 父进程阻塞等待,子进程退出(父进程后面。暂时不会调用)
  • ② 回收子进程残留资源
  • ③ 获取子进程结束状态(退出原因)。
#include <sys/types.h>	//数据类型定义
#include <sys/wait.h>	//提供进程等待的函数
代表系统调用函数
wait();
waitpid()
1
2
3
4
5

linux中所有的异常退出,都是由于信号导致的,由于子进程收到了某个特殊信号。他才异常退出。 比如,段错误,收到了一个引发段错误的信号 注意:一次wait函数调用,能回收1个子进程

# 2、waitpid

作用同wait,但可指定pid进程清理,可以不阻塞 比wait更灵活。 参3: 0 (wait)阻塞回收 WNOHANG:非阻塞回收(轮询) 轮询: waitpid返回值是pid 什么时候,waitpid会返回0值:参3传入WNOHANG,并且子进程尚未结束

# 2、fork出的满二叉树

  • 都知道fork出的进程是一个满二叉树,但仅仅这样还是不能彻底理解,根据代码运行的结果,我们可以这样描述产生的所有进程:

fork生成的满二叉树

fork();
getpid();
getppid();
1
2
3
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int var = 34;

int main(void)
{
    pid_t pid;

    pid = fork();
    if (pid == -1 ) 
    {
        perror("fork");
        exit(1);
    } 
    else if (pid > 0) 
    {
        //获得子进程id之后,我睡一会
        //睡觉的原因:fork之后,父进程还是子进程先执行不确定,取决于『内核』所使用的调度算法
        sleep(2);
        var = 55;
       	//如果在shell中运行,主要的进程的父进程,就是bash
        printf("I'm parent pid = %d, parentID = %d, var = %d\n", getpid(), getppid(), var);
    } 
    else if (pid == 0) 
    {
        //子进程,本身被创建处理之后,向上溯源
        var = 100;
        printf("child  pid = %d, parentID=%d, var = %d\n", getpid(), getppid(), var);
    }
    printf("var = %d\n", var);

    return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# 3、fork父子详解

  • 父子进程相同:
1、全局变量『原因见到3』
2、.text	//那是自然,fork出的,代码执行逻辑自然没变,自然.text相同——————但是,fork出来的『子进程执行』位置不同
3、.data	//数据段,自然也是,这个里面毕竟放的『已经初始化好的全局变量,静态变量
4、栈
5、堆
6、环境变量
7、用户ID	//比如是root执行
8、宿主目录
9、进程工作目录
10、信号处理方式
...
1
2
3
4
5
6
7
8
9
10
11

父子共享的:

1、文件描述符(打开文件的结构体)	//很自然的,这样IPC不就可以了??
2、mmap建立的映射区(进程间通信
1
2
  • 父子不同
1、进程ID	『毕竟,都突然变成2个进程了
2、父进程ID	『毕竟,每个进程的父亲是唯一的,谁fork出来的,还是要不忘本
3、fork返回值	『父进程调用fork获得子进程的ID,转而,子进程出生的那一刻,从这个函数那个地方执行开始新的故事,这个时候,fork一切从0开始
    
PS:上述3个概念,见『fork出的满二叉树』这一节的代码测试
4、进程运行时间	『毕竟子进程才出生多久
5、闹钟(定时器)	『毕竟,这个进程是要被调度的,需要定时器
6、未决信号集
1
2
3
4
5
6
7
8

# 4、wait和waitpid回收子进程

# (1)wait

  • 回收任意子进程
  • 『回收时候的调用方式,是真的很新颖!!
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/wait.h>

int main(void)
{
	pid_t pid, wpid;
	pid = fork();

	if(pid == -1)
	{
		perror("fork error");
		exit(1);
	} 
	else if(pid == 0)
	{		//son
		printf("I'm process child, pid = %d\n", getpid());
		sleep(7);				//困了...
	} 
	else 
	{
	lable:
		wpid = wait(NULL);		//死等!!!,,,,,回收任意子进程
		if(wpid == -1)
		{
			perror("wait error");
			goto lable;
		}
		printf("I'm parent, I catched child process,"
				"pid = %d\n", wpid);
	}

	return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# (2)waitpid

  • 回收指定的子进程
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/wait.h>

int main(void)
{
	pid_t pid, pid2, wpid;
	int flg = 0;

	pid = fork();
	pid2 = fork();

	if(pid == -1){
		perror("fork error");
		exit(1);
	} else if(pid == 0){		//son
		printf("I'm process child, pid = %d\n", getpid());
		sleep(5);				
		exit(4);
	} else {					//parent
		do {
			wpid = waitpid(pid, NULL, WNOHANG);
            //wpid = wait(NULL);
			printf("---wpid = %d--------%d\n", wpid, flg++);
			if(wpid == 0){
				printf("NO child exited\n");
				sleep(1);		
			}
		} while (wpid == 0);		//子进程不可回收

		if(wpid == pid){		//回收了指定子进程
			printf("I'm parent, I catched child process,"
					"pid = %d\n", wpid);
		} else {
			printf("other...\n");
		}
	}

	return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

# 5、exec函数族

  • 毕竟fork出来的,没有做其他完全不相关逻辑的程序,所以用exec函数族
  • 这一族函数,由于最终所有的函数底层都调用了『execve』函数,所以只有它才是『系统调用』,其他函数只是在这个上面包装的假的系统调用

# 6、dup和dup2的使用

  • 文件描述符的复制,有地方称之为:重定向文件描述符
  • 『其实,复制这个叫法比重定向这个叫法更加确切一点』
  • 上面2个函数都能实现这个效果

# 文件锁(借助fcntl函数来实现锁机制)

(记忆方法:file control)

操作文件的进程没有获得锁时,可以打开,但无法执行read、write操作。 fcntl函数:获取、设置文件访问控制属性。

我们以前用这个修改过,阻塞和非阻塞

int fcntl(int fd, int cmd, ... /* arg */ );
1

参2:

F_SETLK (struct flock *)	设置文件锁(trylock)
F_SETLKW (struct flock *) 设置文件锁(lock)W --> wait
F_GETLK (struct flock *)	获取文件锁
1
2
3

【思考】:多线程中,可以使用文件锁吗? 多线程间共享文件描述符,而给文件加锁,是通过修改文件描述符所指向的文件结构体中的成员变量来实现的。因此,多线程中无法使用文件锁。

# 4.进程间通信(IPC)『7侠传』⭐️

  • 实际上在Linux发展历史上,进行进程间通信的方式有很多种。在进程间完成数据传递需要借助操作系统提供特殊的方法
  • 随着计算机的蓬勃发展,一些方法由于自身设计缺陷被淘汰或者弃用。

IPC,InterProcess Communication

# 4.0.文件完成IPC(最易)

  • 使用文件就能完成IPC(很显然
  • fork之后,父子进程,共享文件描述符,也就共享打开的文件
  • 由于,可以通过的文件,一般是在磁盘上,所以,笔者,都想给他取个名字『共享外存』2333

# 4.1.管道

# (1)pipe(无名/匿名管道)

  • 最基本的1种IPC机制
1、『有血缘关系,进程,完成数据传输
2、本质是1个伪文件,实为(内核缓冲区)
1
2

# (2)fifo(命名管道)

  • 只能用于『有血缘关系』的进程间

# 4.5.共享内存(Shared Memory)/共享『存储』映射

  • 我们一般叫『共享内存』,但是笔者根据下面所说,我觉得,存储应该包括下面

所谓存储:

1、内存

2、外存,比如,U盘,硬盘

(1)共享文件描述符(最易)

  • 比如fork

(2)共享内存

  • 存储映射I/O『Memory-mapped I/O』,或许mmap函数是由于这个来的
  • 原理:
1、存储映射I/O使1个“磁盘文件”与“存储空间”中的1个缓冲区相映射。这样,我们就可以在不使用read和write的情况下,使用地址(指针)完成I/O操作『『把外存中的东西,映射到内存中之后再操作』』
2、所以:首先,我们需要通知内核,将1个指定的文件映射到存储区域中。这个映射可以通过mmap函数来实现
1
2
  • 从原理容易知道:『在有血缘和无血缘关系都可以进行通信
# 『1』mmap函数
  • 返回创建的『映射区』首地址
# 『2』munmap函数
  • 与malloc函数申请内存空间类似,mmap建立的映射区在使用结束后也应该调用类似free的函数来释放
  • 就是在mmap函数中加上了un
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>

void *smalloc(size_t size)
{
	void *p;

	p = mmap(NULL, size, PROT_READ|PROT_WRITE, 
			MAP_SHARED|MAP_ANON, -1, 0);
	if (p == MAP_FAILED) {		
		p = NULL;
	}

	return p;
}

void sfree(void *ptr, size_t size)
{
	munmap(ptr, size);
}

int main(void)
{
	int *p;
	pid_t pid;
	
	p = smalloc(4);

	pid = fork();				//创建子进程
	if (pid == 0) 
    {
		*p = 2000;
		printf("child, *p = %d\n", *p);
	} 
    else 
    {
		sleep(1);
		printf("parent, *p = %d\n", *p);
	}

	sfree(p, 4);

	return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

# 4.6.套接字(Socket)

  • 『最稳定』的方式
  • 比如,我们的『网络程序,就是2个或多个进程之间通信,使用Socket

# 4.7.比如C语言中,新建文件『已经被弃用』

  • 弃用,开销大,稳定性低)

# Linux下7种文件类型(重要)

  • 文件 实际占用磁盘存储空间
  • d 目录 占用
  • l符号链接 占用——特殊一点,他记录的是你链接的路径。
  • 只有上面的3种占用,下面4种都不占用。下面4种,我们统一称它为伪文件,因为他们不是真正的文件 他也不会占用磁盘存储。
  • 套接字
  • b块设备
  • c字符设备
  • p管道

# Q:为什么多线程下载更快?

A:由于线程是最小的执行单位,假设每次给任何一个线程一个CPU时间片,那么你的软件拥有的线程越多显然相同时间内能用的CPU时间片越多,执行起来会快一些。因此多线程能够提高程序的执行效率。

# 4.4.Linux下线程的『实现原理』

  • 1)创建线程使用的底层函数和进程一样,都是clone 就是说,pthead_create和fork底层都是调用clone函数。
  • 2)从内核(操作系统来看)里看进程和线程是一样的,都有各自不同的PCB,但是PCB中指向内存资源的三级页表是相同的
  • 因为操作系统区分进程和线程,以PCB作为区分依据,PCB叫做进程控制块/进程描述符,,正是因为这个PCB的这个,因此把PCB分配给一个线程,他就伪装成一个进程,因此CPU在分配时间片的时候,他才会把一个线程也分配一个自己的时间片,加快执行效率。(根本原因:因为,内部在实现的时候,有一个关于内存映射的这样一个问题)

# 1)三级页表

什么叫三级页表?

01

有的指针指向一个空间,我们叫页表。 如图,一个个目录项都是一个指针。 如上图,上面采用的三级映射的方法,4个东西,整个称之为3级页表。 三级映射和我们之前讲过的 虚拟内存和物理内存映射是什么关系?? 我们上面的,三级页表实际上就是在描述,MMU怎么样帮你把虚拟地址,映射到物理地址! 上面的是简略的图,实际上的不能直接从用户空间对应过去。 MMU有个映射表,显然这个会和我们的三级页表进行对应,其实,映射表是保存在内核空间的。

注意图片中,新建的线程,虽然PCB是和前面的进程独立的,但是那个指针还是一样的(注意)

线程与线程之间,肯定整个地址空间不会完全相同,最起码要保证运行指令不一样。(不然,你复制有意义吗?)

  • 线程可看做『寄存器和栈』的集合(栈,线程在执行过程中,我们执行的主要依据是:函数调用,因为线程1和2内部的函数是不一样的) 注意,强调了栈

实际上分配空间,就是两个指针,esp和ebp的移动。 原先是重合,后面慢慢拉开,形成栈帧(帧,一张张,我们知道,每一个函数有属于自己运行的栈帧空间)

(注意:栈帧里面放局部变量和临时值(比如,某函数被调用,那他要保留,原先的ebp和esp,这样这个函数被调用完之后,才能返回去))

如上分析: 每一个线程在调用的时候,都有自己的函数调用。那么每一个线程的stack空间(用户空间中的栈),不能一样。不然,线程与线程之间,无法区分。

上面的内核区,其实还有内核栈,它的作用,主要是用来保存寄存器的值。 什么时候需要保存??进程在要切换的时候,因为CPU要把时间轮片分给不同的进程。 现在变成线程的概念,现在CPU要把这个分配给线程运行,那么线程需不需要保存寄存器的值?显然需要,所以,显然线程需要有属于自己的内核栈空间。 (所以,能理解寄存器和栈那些了)

Linux下CPU划分时间轮片,是依据什么? lwp号(其实叫,轻量级线程,但是我们也可以叫,线程号,,)

Linux下命令

ps -Lf 3500
1

比如,这样,能知道3500这个进程下有哪些线程?

比如,Firefox里面采用的线程池机制,同时开了好多个。 所以,可能我们只打开了几个页面,却发现有好多个线程。

lwp是线程号,但是不是线程id,注意

  • 线程号的作用:CPU分配时间仑片的作用
  • 线程id的作用:是进程内部区分线程的!!!!

同一个进程中的线程,他们的PCB虽然是不一样的,但是其中的三级页表却是相同的。

# 2)线程优点和缺点

  • 线程共享资源 1.文件描述符表 2.每种信号的处理方式(由于,线程和信号都很麻烦,编程的时候,能够减少他们合体就尽量避免——
  • 信号和进程是早期就有的,但信号复杂,线程是后期才有的。 3.当前工作目录(工作目录是根据进程定的) 4.用户ID和组ID 5.内存地址空间 (.text/.data/.bss/heap/共享库),0-3G就把那个stack给排除了。
  • 线程非共享资源 1.线程id 2.处理器现场(即,寄存器的值)和栈指针(内核栈) 3.独立的栈空间(用户空间栈) 4.errno变量(这个变量,是个全局变量,是放在.data段,但是,他很特殊,每个线程独享。注意!!) 5.信号屏蔽字(毕竟,线程概念是后面来的) 6.调度优先级

# 5.0.线程库版本

  • NPTL实现机制(POSIX),Native POSIX Thread Library
  • 所谓NPTL就是你需要在使用线程的时候,注意一下使用的线程库的版本是什么。(原因是,我们编写的程序,可能跨平台开发(库的版本不一样,可能导致程序运行失败,甚至异常)
  • 我们先前用的都是POSIX标准下面所默认推荐的线程库。
  • 我们知道Linux下面,都是GNU组织给我们提供的lib库
  • 你用这个可以查询当前库的版本

1、查看当前pthread库版本

getconf GNU_LIBPTHREAD_VERSION
1

比如我的阿里云服务器

[略 ~]# getconf GNU_LIBPTHREAD_VERSION
NPTL 2.28
1
2

2.NPTL实现机制(POSIX),Native POSIX Thread Library 3.使用线程库时gcc指定–lpthread

# 5.1.线程属性

一般情况下,实际上我们在做开发的时候,我们线程的默认属性是能够满足我们大多数情况下的需求的。 极个别的时候,线程的默认属性不满足,就需要我们自己来设定。

  • 如我们对程序的性能提出更高的要求那么需要设置线程属性,比如可以通过设置线程栈的大小来降低内存的使用,增加最大线程个数。
typedef struct
{
    int 					etachstate; 	//线程的分离状态
    int 					schedpolicy; 	//线程调度策略
    struct sched_param	schedparam; 	//线程的调度参数
    int 					inheritsched; 	//线程的继承性
    int 					scope; 		//线程的作用域
    size_t 				guardsize; 	//线程栈末尾的警戒缓冲区大小
    int					stackaddr_set; //线程的栈设置
    void* 				stackaddr; 	//线程栈的位置
    size_t 				stacksize; 	//线程栈的大小
} pthread_attr_t;
1
2
3
4
5
6
7
8
9
10
11
12

# 记忆-进程和线程控制原语对比

进程 线程
fork pthread_create
exit pthread_exit
wait pthread_join
kill pthread_cancel
getpid pthread_self 命名空间

4)pthread_join函数(用于,线程回收)

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

5)pthread_detach函数(线程分离)

这个函数在进程当中没有对应的 实现线程分离

int pthread_detach(pthread_t thread);	成功:0;失败:错误号
线程分离状态:指定该状态,线程主动与主控线程断开关系。线程结束后,其退出状态不由其他线程获取,而直接自己自动释放。

网络、多线程服务器常用。(很重要,因为状态分离后就不会产生僵尸进程了)

从状态上实现了分离,好处如下:
进程若有该机制,将不会产生僵尸进程。僵尸进程的产生主要由于进程死后,大部分资源被释放,一点残留资源仍存于系统中,导致内核认为该进程仍存在。
因此在,网络、多线程服务器常用。
也可使用 pthread_create函数参2(线程属性)来设置线程分离。
1
2
3
4

记得有书上说,虽说理论上,线程创建好之后,就和父进程在等同的地位上抢夺CPU 但是这个繁复做实验,发现,一般情况下是主控线程先执行,一般线程后执行。当然,只是科普,不敢这么写代码

6)pthread_cancel函数(杀死线程)

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

kill是通过通过发信号杀死的,但是线程的这个函数杀死成功率很高。

int pthread_cancel(pthread_t thread);	成功:0;失败:错误号
1

# 6.线程同步⭐️

  • 主控线程
  • 子线程本章讲到的和线程相关的锁有4种。 1)互斥量(互斥锁) 2)读写锁 3)条件变量(线程当中一种常见的锁机制) 4)信号量:互斥量升级版,一系列函数为sem_开头的 发现,少了那个pthread关键字,所以说,信号量不单单能用于线程间同步,还能用于进程间同步**! sem_

# 6.1同步的概念

所谓同步,即同时起步,协调一致。不同的对象,对“同步”的理解方式略有不同 如,硬件上的设备,设备同步,是指在两个设备之间规定一个共同的时间参考

  • 数据库同步,是指让两个或多个数据库内容保持一致,或者按需要部分保持一致
  • 文件同步,是指让两个或多个文件夹里的文件保持一致。等等
  • 而,编程中、通信中所说的同步与生活中大家印象中的同步概念略有差异。
  • “同”字应是指协同、协助、互相配合
  • 主旨在协同步调,按预定的先后次序运行。各个行业,对同步的理解方式是不一样的!

不同的对象,对同一个数据同时进行操作,这个数据,我们称为共享数据或者叫共享资源

注意:

在Linux,用户层面上编程所用到的所有的锁,我们都把他称之为建议锁。 所以这个,指的是Linux内核建议你在访问共享数据的时候,加一把锁再访问。是否具有强制性? 不具有! 因此访问共享数据的所有线程,要想保证数据不出现混乱。 都应该先加锁后访问才行!!!

如果像前面那样的,直接访问,会导致数据尴尬的。

描述的锁,如何在应用程序中表示出来呢?

就需要一个变量表示出来。这个变量叫做互斥量(或者互斥锁)

# 6.2.四种锁机制

# 6.2.1.互斥量mutex

每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。 资源还是共享的,线程间也还是竞争的, 但通过“锁”就将资源的访问变成互斥操作,而后与时间有关的错误也不会再产生了。

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
1

注意到上面有个关键字: restrict关键字:只用于限制指针,告诉编译器,所有修改该指针指向内存中内容的操作,只能通过本指针完成。不能通过除本指针以外的其他变量或指针修改

# 6.2.2.读写锁

  • 读写锁也叫共享-独占锁

读写锁(相较于互斥锁来说,性能稍微高一些)

注意:读写锁,锁只有一把,不是两把 与互斥量类似,但读写锁允许更高的并行性。 其特性为:写独占,读共享

这把锁,既可以用“读”的方式对变量加锁,而且还能以“写”的方式对变量加锁

读写锁状态:

一把读写锁具备三种状态:

  1. 读模式下加锁状态 (读锁)
  2. 写模式下加锁状态 (写锁)
  3. 不加锁状态

掌握读写锁,记住下面就好了: 写锁优先级高,写独占、读共享

读写锁特性:

1.读写锁是“写模式加锁”时, 解锁前,所有对该锁加锁的线程都会被阻塞。 2.读写锁是“读模式加锁”时, 如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞。 3.读写锁是“读模式加锁”时, 既有试图以写模式加锁的线程,也有试图以读模式加锁的线程。那么读写锁会阻塞随后的读模式锁请求。优先满足写模式锁。读锁、写锁并行阻塞,写锁优先级高

读写锁也叫共享-独占锁 当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。写独占、读共享。 读写锁非常适合于对数据结构读的次数远大于写的情况

# 6.2.3条件变量(条件变量本身不是锁!)

条件变量:(一定要满足某个条件,才能咋咋咋样) 条件变量本身不是锁!但它也可以造成线程阻塞。 通常与互斥锁配合使用。给多线程提供一个会合的场所。

会合的场所:指的共享数据

主要应用函数:

1)pthread_cond_init函数

2)pthread_cond_destroy函数

3)pthread_cond_wait函数(难点)

它可以干3件事情 阻塞等待一个条件变量

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
1

函数作用: 1.阻塞等待条件变量cond(参1)满足 2.释放已掌握的互斥锁(解锁互斥量)相当于pthread_mutex_unlock(&mutex); ** 1.2.两步为一个原子操作。** 3.当被唤醒,pthread_cond_wait函数返回时,解除阻塞并重新申请获取互斥锁pthread_mutex_lock(&mutex);

谁来唤醒?? pthread_cond_signal函数 唤醒至少一个阻塞在条件变量上的线程

pthread_cond_broadcast函数(broadcast,广播,计算机网络,网络编程中也讲这个) 唤醒全部阻塞在条件变量上的线程

4)pthread_cond_timedwait函数

5)pthread_cond_signal函数

6)pthread_cond_broadcast函数

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

pthread_cond_t类型 用于定义条件变量 pthread_cond_t cond;

应用场景呢?

线程同步—“生产者消费者”条件变量模型

线程同步中最最知名的一个模型。 只要提到,几乎都会提到这个模型。

  • 假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。 两个线程同时操作一个共享资源(一般称之为汇聚),生产向其中添加产品,消费者从中消费掉产品。

显然,这个的条件变量就是:已经生产了产品

看如下示例,使用条件变量模拟生产者、消费者问题:

#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

struct msg {
    struct msg *next;
    int num;
};
struct msg *head;

pthread_cond_t has_product = PTHREAD_COND_INITIALIZER;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *consumer(void *p)
{
    struct msg *mp;
    for (;;) {
        pthread_mutex_lock(&lock);
        while (head == NULL) {           //头指针为空,说明没有节点    可以为if吗
            pthread_cond_wait(&has_product, &lock);
        }
        mp = head;      
        head = mp->next;    			//模拟消费掉一个产品
        pthread_mutex_unlock(&lock);

        printf("-Consume ---%d\n", mp->num);
        free(mp);
        sleep(rand() % 5);
    }
}
void *producer(void *p)
{
    struct msg *mp;
    while (1) {
        mp = malloc(sizeof(struct msg));
        mp->num = rand() % 1000 + 1;        //模拟生产一个产品
        printf("-Produce ---%d\n", mp->num);

        pthread_mutex_lock(&lock);
        mp->next = head;
        head = mp;
        pthread_mutex_unlock(&lock);
    
        pthread_cond_signal(&has_product);  //将等待在该条件变量上的一个线程唤醒
        sleep(rand() % 5);
    }
}
int main(int argc, char *argv[])
{
    pthread_t pid, cid;
    srand(time(NULL));

    pthread_create(&pid, NULL, producer, NULL);
    pthread_create(&cid, NULL, consumer, NULL);
    
    pthread_join(pid, NULL);
    pthread_join(cid, NULL);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

总结

互斥量在使用的时候,要注意把握一下,锁的**”粒度“**(或者叫做锁的“临界区”) 建议,锁的粒度(临界区)越小越好。 此处和《程序员的自我修养-链接,装载与库》说的临界区咋不一样。。

# 条件变量的优点:(为什么我们要引入这么多种锁的机制?)

为什么我们要引入这么多种锁的机制??最开始的互斥锁,不是已经可以完成保护数据的目的了吗?为什么还要引入读写锁呢?因为在读比写更多的场景,读写锁的效率比互斥锁高。那又为什么要引入条件变量呢?因为条件变量相对于我们的互斥量而言,它也可以减少一些不必要的竞争啊 原因如下: 相较于mutex而言,条件变量可以减少竞争。

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

# 6.3.信号量

注意,信号和信号量没有关系。就像java和JavaScript没关系一样。

信号量的初值,决定了占用信号量的线程的个数。

信号量对于线程同步来说,可以把它理解成一个进化版的互斥锁。 比如,互斥量初始化之后是1,强调这个是为了给信号量做铺垫。 而我们的信号量初始化之后是N。

原来,互斥锁,只能供1个线程同时使用 现在,信号量,可以指定成N个线程同时过来,获取这把锁。(提升了,共同访问共享资源的线程数量) 好比 买了量车,小轿车可以载4个人(带4个线程) 买了两人座的,那么可以载1人(带一个线程)

进化版的互斥锁(1 --> N)

  • 互斥锁的粒度比较大,如果我们希望在多个线程间对某一对象部分数据进行共享,使用互斥锁是没有办法实现的,只能将整个数据对象锁住。这样虽然达到了多线程操作共享数据时保证数据正确性的目的,却无形中导致线程的并发性下降。线程从并行执行,变成了串行执行。与直接使用单进程无异。
  • 信号量,是相对折中的一种处理方式,既能保证同步,数据不混乱,又能提高线程并发。

# 多线程『编程模型』⭐️reactor 模式和peactor 模式

  • actor,演员;像在演戏的人;参与者

# 1.Reactor 模式

  • Reactor,核反应堆;阳性反应者;电抗器;反应器

  • 特别是 Reactor 模式,市面上常见的开源软件很多都采用了这个方案,

    • 比如 Redis、Nginx、Netty 等等,所以学好这个模式设计的思想,有助于我们理解很多开源软件,有助于面试

    我们熟悉的 select/poll/epoll 就是内核提供给用户态的多路复用系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。

『演变如下』

  • 当下开源软件能做到网络高性能的原因就是 I/O 多路复用吗?
  • 是的,基本是基于 I/O 多路复用,用过 I/O 多路复用接口写网络程序的同学,肯定知道是面向过程的方式写代码的,这样的开发的效率不高
  • 于是,大佬们基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。

大佬们还为这种模式取了个让人第一时间难以理解的名字:Reactor 模式

  • Reactor 翻译过来的意思是「反应堆」,可能大家会联想到物理学里的核反应堆,实际上并不是的这个意思。这里的反应指的是「对事件反应,也就是来了一个事件,Reactor 就有相对应的反应/响应
  • 事实上,Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程

# 2.Proactor模式

  • Proactor,网络释义:前摄器 ; 主动器 ; 前摄器模式
  • 前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式

参考:小林Coding,图解高性能服务器开发两种模式! (opens new window)