操作系统是管理计算机硬件与软件资源的计算机程序。操作系统是计算机中负责支撑应用程序运行环境以及用户操作环境的系统软件。
二、操作系统
1、操作系统体系结构
在操作系统中需要对执行权限进行分级,分为用户态和内核态,所以。
1.1 内核态与用户态
💗 1.1.1 内核态
内核处于硬件和应用软件之间的中间层,其作用是将应用程序的操作和请求传递给硬件,并充当底层驱动程序,对系统中的各个设备和组件进行寻址,并管理和分配资源。内核面向应用程序提供了系统调用,保证了应用程序的开发与硬件没有任何联系,应用程序只与内核有关。
内核的实现主要有两种方式:微内核与宏内核
系统内核的体系结构主要分为7个部分,如下图所示:
Q1. 为什么有内核态和用户态 ?
操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。为了减少有限资源的访问和使用冲突,对不同的操作赋予不同的执行等级。
Linux
操作系统中主要采用了0和3两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。
Q2. 内核态和用户态的区别 ?
内核态中,CPU可以访问内存的所有数据,也可以从一个程序切换到另一个程序。而用户态只能受限的访问内存,且用户态相比内核态有较低的执行权限。进程在用户态时,只能访问用户空间内存。只有进入内核态后,才可以访问内核空间的内存;
Q3. 内核态与用户态的切换 ?
当出现以下三种情况时,会发生从用户态到内核态的切换:
① 系统调用:系统调用的本身也是中断,相对于外围设备的硬中断,这种中断称为软中断,其中断请求是进程主动申请的。
② 异常:当CPU正在执行运行在用户态的程序时,发生某些预先不可知的异常事件,这个时候就会触发从当前用户态执行的进程转向内核态执行相关的异常事件,如缺页异常。
③ 外围设备的中断。
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/02eb9ebe24397610c26a2847a2e59d84.png)
2、程序=>进程=>线程=>协程
进程与线程是操作系统的核心,是系统应用程序运行的基础。程序,进程,线程不是单独割裂的,是一种相互依赖,相互递进的关系。
2.1 程序,进程与线程关系
Q1. 程序与进程有什么区别?
① 程序是永存的,进程是暂时的,进程是程序在数据集合上的一次运行,进程有创建,有撤销,可以终止。
② 程序是静态的,进程是动态的。
③ 进程具有并发性,而程序没有。
④ 进程是计算机资源的基本单位。
⑤ 进程与程序不是一一对应的关系,一个程序可能包含很多进程。
Q2. 什么是进程?
广义来说,进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,进程是操作系统动态执行的基本单元,进程是系统资源分配的独立实体。线程作为调度和分配的基本单位,进程作为资源分配的基本单位。 一个进程的组成实体可以分为两大部分:线程集合和资源集合。
狭义来说,一个进程就是一个正在执行的程序实例,包括程序计数器,寄存器和变量的当前值。
Q3. 进程的特性?
① 动态性:进程的实质是一次程序执行的过程,有创建、撤销等状态的变化。
② 并发性:进程可以做到在一个时间段内,有多个程序在运行中。
③ 独立性:进程可以独立的分配资源,接受调度,独立运行。
④ 异步性:进程之间是相互制约的。
⑤ 结构性:进程是有结构的。
Q4. 为什么要引入线程,什么是线程?
由于进程是一个资源的拥有者,因而在创建,撤销,切换中,需要保存进程上下文,系统必须为之付出较大的开销。为了多个程序更好的并发执行,同时有减少系统的开销,从而引入了线程,并使线程作为系统调度的基本单位,而进程仅仅是操作系统资源分配的基本单位一个进程可以并发多个线程。 同一进程中的多条线程的资源关系图下图所示:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/fc6a8fefb0ca8fcf8053b8acad0cf3b7.png)
Q5. 线程与进程的区别 ?
① 根本区别: 进程是操作系统动态执行的基本单元,线程是资源调度最小单元。
② 地址空间:进程有自己独立的地址空间,每启动一个进程,系统会为其分配地址空间。线程没有独立的地址空间,同一进程的线程共享本进程地址空间。
③ 资源:进程之间的资源是独立的;同一进程内的线程共享本进程的资源。
④ 执行过程:每个进程都有一个程序运行入口,但线程不能独立运行,必须依存在进程当中。
⑤ 系统开销:进程的开销大,线程的开销小。
2.2 线程的分类及实现方式
💗 2.2.1 线程的分类
线程作为调度和分配的基本单位,进程作为资源分配的基本单位。在线程概念中,主要分为3种线程:内核线程,轻量级线程(LWP
),用户线程。
💗 2.2.2 线程的实现方式
线程有三种分类,同时线程有3种实现方式:用户级线程(ULT
),内核级线程(KLT
)和混合式线程。
2.3 进程创建/终止/调度
进程作为一个程序实例,其运算过程分为四个部分:进程创建,进程终止,进程调度,进程间通信。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/c17dc36ec2cd2a3d222625758d988060.png)
以下
4
个事件会导致进程的创建,但是
所有情形中,新进程都是由于一个已存在的进程执行了一个用于创建进程的系统调用而创建的。
① 系统初始化 ② 正在运行的程序进行创建进程的系统调用
③ 用户请求创建一个新进程 ④ 一个批处理作业的初始化
💗 2.3.2 进程的终止
以下4个事件会导致进程的终止:
① 正常退出(自愿) ② 出错退出(自愿) – 程序运行的某些条件不存在
③ 严重错误(非自愿) – 引用不存在的内存,非法指令 ④ 被其他进程杀死(非自愿)
💗 2.3.3 进程的调度
由于计算机的CPU数量有限,但是计算机中有很多个进程,这就会造成多个进程会同时竞争CPU,因此这就需要 进程调度程序选择下一个要运行的进程。进程的调度通常伴随着进程状态的改变,因此首先介绍进程的状态关系。
1. 进程的状态
进程的状态通常分为三种: 运行态, 就绪态, 阻塞态。状态切换②和③是由进程调度程序引起的。 进程的调度是操作系统的一部分。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/de0438fb023c1acca809ca3cf99fbc5b.png)
因为 进程的调度需要从用户态切换到内核态,并保存当前的状态,如果进程切换过于频繁,会耗费大量CPU时间。因此进程的调度需要考虑 进程的计算方式、 何时进行进程调度和 采用什么样的进程调度算法的问题。
● 进程计算方式:进程计算方式分为两类:
① 计算密集型:CPU的运算时间要占大多数。
② I/O密集型:进程在等待I/O上花费大多数时间。
● 何时进行进程调度:通常在4种情形中需要进程调度:
① 在创建新进程后,决定是运行父进程还是子进程。
② 在进程退出时需要进程调度。
③ 在进程阻塞时需要进程调度。
④ 在I/O中断发生时需要进程调度。
● 进程调度算法
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/a57bd9a54b81810aaa8f8c7e75ff9309.png)
2.4 进程/线程间通信
在不同的进程之间经常需要相互通信,这就形成了进程间通信(Inter Process Commucation,ICP)
。进程间通信需要考虑是三个问题:
① 一个进程如何把信息传递给另一个进程?–通信方法
② 如何确保多个进程在通信过程中不存在交叉?-- 互斥
③ 如何保证进程之间的执行顺序? – 同步
💗 2.4.1 进程间通信方式
进程间通信方式主要有:
1. 共享内存
共享内存就是允许两个或多个进程共享一定的存储区。将共享内存进行访问的程序片段称作临界区。因为数据不需要在客户机和服务器端之间复制,数据直接写到内存,不用若干次数据拷贝,所以这是最快的一种IPC。共享内存中没有任何互斥和同步机制。注意,在多进程中全局变量并不是共享内存。
共享内存有两种实现机制:
① mmap
机制:在磁盘中建立一个文件,将进程的虚拟内存空间与磁盘的文件地址空间相对应,从而使多进程之间通过映射同一个普通文件实现共享内存。实现映射后,进程就可以采用指针的方式读写这段内存,系统会自动回写脏页面到磁盘中,完成对文件的操作,而不必在调用write
和read
系统调用函数。
② shm
机制:多个进程的地址空间都映射到同一块物理内存,这样多个进程都能看到这块物理内存,实现进程间通信,而且不需要数据的拷贝,所以速度最快。当系统断电后,其中的内存数据会全部自行销毁。
Q1. mmap与shm的区别 ?
mmap
是保存到硬盘当中,存储量大,但进程间的读取和写入数据要比主存慢。而shm
保存到主存中,进程间访问速度比磁盘快,但存储量不大,不能持久保存。
2. System V 信号量
在这里首先介绍一下信号量,在Linux系统中,存在多个信号量,其调用函数与调用方式不相同,但原理相同。
在进程间通信过程中,多个进程会竞争共享资源,为了防止出现因多个进程同时访问一个共享资源而引发数据不同步问题,引入System V
信号量来保证多个进程同步的访问共享资源。进程IPC
中使用的System V
信号量,它是一个集合一个或者多个信号量的集合,利用PV原语进行操作。
3. 管道 pipe
一个进程连接到另一个进程的一个数据流称为管道。管道的本质是内核的一块缓存,由两个文件描述符引用,一个表示读端,一个表示写端,数据从写端流入,从读端流出。管道通信分为匿名管道和命名管道。匿名管道只能在有亲缘关系的进程间使用,命名管道可在同一台计算机的不同进程之间或在跨越一个网络的不同计算机的不同进程之间使用。
● 匿名管道
Q1. 匿名管道有什么特点 ?
① 一个进程只能控制读端或写端,不能同时自己读写。而如果需要两者同时进行时,就得重新打开一个管道。
② 管道中的数据不可反复读取
③ 管道采用半双工通信方式,数据只能在一个方向上流动
④ 管道内部传输的数据是字节流。
⑤ 匿名管道只能是具有血缘关系的进程间通信。
匿名管道通信有4种读写情况:
● 命名管道 - FIFO
命名管道本质上是一个FIFO
,FIFO
是一个设备文件,在文件系统中以文件名的形式存在,即使进程与创建FIFO的进程没有血缘关系也可以进行通信。FIFO
文件遵循先入先出原则,先放入管道的数据,在端口首先被读出。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/0ed83634c1e88b894e62c15091f9990e.png)
4. UNIX域套接字
Q1. UNIX域套接字有什么特点 ?
UNIX域套接字是进程间通信(IPC)的一种方式。UNIX域套接字不同于传统的网络套接字Socket,其区别如下:
● UNIX域套接字是可靠的,不会丢失消息,也不会传递出错,而网络套接字Socket中,UDP不可靠,TCP需要通过协议来保证其可靠性。
● UNIX域套接字仅仅复制数据,并不执行协议的处理,不需要添加各种网络报头,无需计算校验和,不产生序列号,也不需要发送确认报文,因此其在本地主机中传输速度是网络套接字Socket的两倍。
● UNIX域套接字可以在同一台主机上各进程之间传递文件描述符。
● UNIX域套接字域使用路径名表示协议族的描述。
Q2. UNIX域套接字有哪些 ?
常用的UNIX域套接字是指socketpair()
函数:
socketpair()
函数会创建一对无名的,相互连接套接字,socketfd[0](写端),socketfd[1](读端)
。与管道pipe()
通信不同的是:
① socketpair()
可以用于全双工通信,每个套接字既可以读,也可以写。
② 如果往一个套接字写入后,再从该套接字读出时会造成读阻塞,直到数据到来。
③ 在父子进程中通信时,一般会各关闭一个套接字。否则会出现异常。如在父子进程中,都不关闭读端socketfd[1]
,则当父进程先读时,子进程会读取到空串;若当子进程先读时,父进程会阻塞在read
操作。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/f7de820c6a67c9b099d4a3ccd8bb9263.png)
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
int main(){
int pipefd[2];
if(socketpair(PF_UNIX,SOCK_STREAM,0,pipefd)==-1){
exit(1);
}
pid_t pid=fork();
if(pid<0){
exit(1);
}
if(pid==0){ //children
close(pipefd[1]);
char buf[]="children";
write(pipefd[0],buf,sizeof(buf));
char recvbuf[100];
read(pipefd[1],recvbuf,100);
printf("childern: %s\n",recvbuf);
close(pipefd[0]);
}else{ //parient
close(pipefd[0]);
char buf[]="parent";
write(pipefd[1],buf,sizeof(buf));
sleep(1);
char recvbuf[100];
read(pipefd[1],recvbuf,100);
printf("parent: %s\n",recvbuf);
close(pipefd[1]);
}
5. 消息队列
消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。消息队列提供了一种从一个进程向另一个进程发送一个数据块(struct
数据结构)的方法。消息队列通过发送消息来避免命名管道的同步和阻塞问题,其每个数据块都有一个最大长度的限制。消息队列的数据结构采用双向循环链表方式存储消息,而不是队列。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/ec774973937745a0d531090feb41e955.png)
2.5 多进程/线程的同步互斥(锁机制)
由于进程/线程在计算数据时并不是一次就将数据计算完成的,其过程分为三个部分,如下图所示:
当该数据是一个共享数据时,且有两个或多个进程读写共享数据时,对上述的三个过程会出现进程竞争的现象。解决进程竞争,就是要阻止多个进程同时读写共享的数据(互斥)。同时,为了解决进程执行的顺序问题,就要保证进程的同步。
Q1.同步与互斥有什么区别与联系 ?
① 互斥是对某一资源,同一时间只允许一个访问者对其访问,具有唯一性和排他性。但互斥无法保证访问者对资源的访问顺序,即访问是无序的。
② 同步是在互斥的基础上,实现了访问者对资源的有序访问。因此,同步已经实现了互斥。
💗 2.5.1 多进/线程锁机制
为了保证多进程和多线程的互斥和同步,操作系统提供了锁机制。在操作系统中锁机制分为包含的锁如下图所示:
💗 2.5.2 多进/线程同步互斥的实现 (用户态锁)
1. 信号量
信号量有两种实现方式:传统的System V信号量和Posix信号量,其区别如下:
①. Posix 无名信号量 sem-- 线程同步
Posix 无名信号量=资源计数器+PCB等待队列+线程等待和唤醒接口
无名信号量是一个整型变量来累计唤醒次数。一个信号量的取值≥0。信号量有两种操作:down(sem_wait)
和up(sem_post)
。 无名信号量可以实现进程之间的互斥和同步。一个信号量操作开始后,在该操作完成或阻塞前,其他进程不允许访问该信号量。具体步骤如下:
Step1:测试控制该资源的信号量。
Step2:若此信号量的值为正,则允许进行使用该资源。线程将进号量减1。
Step3:若此信号量为0,则该资源目前不可用,线程进入阻塞状态,直至信号量值大于0,线程被唤醒,转入Step1。
Step4:当线程不再使用一个信号量控制的资源时,信号量值加1。如果此时有进程正在睡眠等待此信号量,则唤醒此进程。
class Semaphore{ //信号量类
private:
sem_t sem; //信号量结构体
public:
Semaphore();
~Semaphore();
bool wait();
bool post();
};
Semaphore::Semaphore(){
if(sem_init(&sem,0,0)==-1){ //创建一个未命名的信号量
printf(" Sem Error: Semaphore init failed\n");
}
}
Semaphore::~Semaphore(){
sem_destroy(&sem); //丢弃信号量
}
bool Semaphore::wait(){
//若成功则返回0,出错则返回-1
int sem_wait(sem_t *sem) //阻塞方式的等待
int sem_trywait(sem_t* sem); //非阻塞方式的等待
int sem_timedwait(sem_t* sem, const struct timespec* timeval) //带有超时时间的等待
}
bool Semaphore::post(){
//唤醒PCB等待队列当中等待该信号量值变为正数的PCB
if(sem_post(&sem)==0) //线程执行完毕,信号量+1
return true;
else
return false;
}
②. Posix 有名信号量 sem-- 线程/进程同步
有名信号量与无名信号量的原理相同,只是其存储位置不同。有名信号量创建后,会在linux的/dev/shm目录下,生成一个sem.name的文件,name为sem_open()的第一个参数。
#include <semaphore.h>
sem_t *sem_open(const char *name, int oflag,
mode_t mode, unsigned int value); //有名信号量创建
int sem_unlink(const char *name); //有名信号量删除
int sem_getvalue(sem_t *sem, int *sval); //获取信号量的value值
//唤醒PCB等待队列当中等待该信号量值变为正数的PCB
int sem_post(sem_t *sem); //线程执行完毕,信号量+1
int sem_wait(sem_t *sem); //阻塞方式的等待
int sem_trywait(sem_t *sem); //非阻塞方式的等待
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout); //带有超时时间的等待
2. 互斥锁 mutex – 进程互斥
互斥锁解决了原子性问题。
互斥锁= PCB等待队列+线程等待和唤醒接口
互斥锁是信号量的简化版本,去除了信号量的计数能力。互斥量使用一个整形量,通常情况下,Mutex=1
,表示互斥锁未使用,进程可以获得锁;Mutex=0
,表示互斥锁已经被某个线程获得。当互斥锁加锁失败时,线程会从用户态陷入到内核态中,通过内核切换线程,因此会进行两次线程的上下文切换。当两个线程同属于一个进程时,因为同一进程的虚拟内存是共享的,因此在切换时,虚拟内存的资源保持不动,只需要切换线程的私有资源,寄存器等不共享的数据。 如果上下文切换的时间比线程锁住的时间长时,则互斥锁会影响系统的性能,此时应该用自旋锁。
互斥锁只实现了进程之间的互斥,不能保证进程之间的同步。同时,大量的线程阻塞会增加线程的竞争,因此,只使用互斥锁可能会导致进程的死锁。互斥锁可以分为可递归锁(可重入锁)和非递归锁(不可重入锁)。
● 非递归锁 - 一个线程多次获取同一个非递归锁会产生死锁
使用者使用互斥锁时在访问共享资源之前进行加锁操作,在访问完成之后进行解锁操作。加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。注意:由于互斥锁是由系统来唤醒,若解锁时,有多个线程阻塞,则该锁上所有阻塞线程都变成就绪状态,但只能有一个线程访问资源并又执行加锁操作,这就导致其他线程的虚假唤醒。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/0dab835b98b26465f203e2a715bb2343.png)
● 递归锁 - 一个线程多次获取同一个非递归锁不会产生死锁。
递归锁以字典为原理,对于同一个线程获取非递归锁不会产生死锁。一般情况下不会使用递归锁。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/0dfe0b1a302b446d98b9557a8157a37e.png)
Q2. Posix信号量与互斥锁的异同 ?
(1). 互斥量用于进/线程的互斥,具有唯一性和排它性,但互斥无法保证进/线程对资源的有序访问。互斥量只能是0或1
。
(2). 信号量用于进/线程的同步,同步时指在互斥的基础上实现进/线程对资源的有序访问,线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。同步是必定互斥的。信号量可以是非负整数。
(3). 互斥锁必须由单个线程获取和释放。信号量是由单个线程释放,另一个线程获取,保证线程同步。
(4). 信号量中,当Semaphore=1时可以看成互斥锁。
3. 条件变量pthread_condition – 进程同步
条件变量是线程间共享资源进行同步的一种机制,条件变量是多线程中用来等待线程的,而不是用来上锁的。条件变量通常与互斥锁一起使用。典型的条件变量应用是消费者与生产者问题,其过程如下图所示:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/6480b9467cfe1bdf03ae7cfb8e68fed7.png)
#include<pthread.h>
#include<stdio.h>
#include<unistd.h>
int count=0; //当前队列个数
int size=10; //模拟队列大小
pthread_t Ppthread[10];
pthread_t Cpthread[10];
pthread_mutex_t mutex;
pthread_cond_t con,con2;
void *producer(void *arg){ //生产者
while(1){
pthread_mutex_lock(&mutex);
while(count>=size){
printf("buffer is full,producer is waitting\r\n");
pthread_cond_wait(&con,&mutex);
}
printf(" @@@@ Ppthread:%lu Producer %d\r\n",pthread_self(),count);
count++;
pthread_cond_signal(&con2);
pthread_mutex_unlock(&mutex);
sleep(3);
}
}
void *consumer(void *arg){ //消费者
while(1){
pthread_mutex_lock(&mutex);
while(count<=0){
printf("buffer is empty,consumer is waitting\r\n");
pthread_cond_wait(&con,&mutex);
}
printf(" #### Cpthread: %lu consumer %d\r\n",pthread_self(),count);
count--;
pthread_cond_signal(&con);
pthread_mutex_unlock(&mutex);
sleep(4);
}
}
int main(){
for(int i=0;i<10;i++){
pthread_create(&Ppthread[i],NULL,producer,NULL);
pthread_create(&Cpthread[i],NULL,consumer,NULL);
}
sleep(60);
}
Q3. 条件变量存在的问题 ?
虽然条件变量与互斥锁一起使用能够实现线程的同步,但其在使用过程中有两个问题需要注意:
① 虚假唤醒问题:在多核处理器下,唤醒操作可能会激活多个等待的线程,从而造成不该唤醒的线程被唤醒,造成虚假唤醒。该问题解决方法是:在线程被激活后还需要检测等待的条件是否满足,如果不满足继续进入wait
状态。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/34a74a5e4b1564d186a869080fec91a8.png)
② 唤醒丢失问题:如果在等待条件变量pthread_cond_wait
前,条件变量就被唤醒激活pthread_cond_signal
,那么这次唤醒就会丢失。有两种方法可以避免唤醒丢失:
● 在使用条件变量,调用signal/broadcast
的时候,无法知道是否已经有线程等在wait
上了。因此,一般要先改变条件状态,然后再发送signal/broadcast
信号。然后在wait
调用线程上先检查条件状态,只有当条件状态为假的时候才进入pthread_cond_wait
进行等待,从而防止丢失signal/broadcast
事件。并且检查条件、pthread_cond_wait
,修改条件、signal/broadcast
都要在同一个mutex
的保护下进行。
● 用信号量代替条件变量,信号量不存在唤醒丢失问题。
4. 读写锁 rwlock – 进程同步
读写锁称为共享互斥锁,其在读模式下共享,在写模式下互斥。读写锁有三种状态:读加锁状态,写加锁状态和不加锁状态。读写锁适合于对数据结构的读次数比写次数多得多的情况。注意:当有多个线程进行读资源,只有少量线程进行写资源,这种情况下会出现,资源一直处于被读状态(写操作无法加锁),导致饥饿现象,解决这一问题,可以通过优先级的方式来解决。
#include<stdio.h>
#include<pthread.h>
#include<stdlib.h>
#include<unistd.h>
struct Ticket{
int remain=10;
pthread_rwlock_t rwlock;
}ticket;
void *query(void *arg){
sleep(rand()%4+1);
pthread_rwlock_rdlock(&ticket.rwlock); //读加锁
printf("%03d ticket remain\n",ticket.remain);
pthread_rwlock_unlock(&ticket.rwlock);
}
void *buy(void *arg){
sleep(rand()%10+1);
pthread_rwlock_wrlock(&ticket.rwlock); //写加锁
ticket.remain--;
printf("buy tocket %03d ticket remain\n",ticket.remain);
pthread_rwlock_unlock(&ticket.rwlock);
}
int main(){
int names[10];
pthread_t tid[10];
for(int i=0;i<10;i++){
if(i%3==0)
pthread_create(&tid[i],NULL,query,NULL);
else
pthread_create(&tid[i],NULL,buy,NULL);
}
for(int i=0;i<10;i++)
pthread_join(tid[i],NULL);
}
5. 自旋锁 spin_lock – 进程同步
自旋锁是使用者在想要获得临界区执行权限时,如果临界区已经被加锁,那么自旋锁并不会阻塞睡眠,而是原地忙轮询资源是否被释放加锁。
自旋锁是通过CPU提供的CAS(Compare And Swap)
函数实现的,其会在用户态完成加锁和解锁操作,不会产生线程的上下文切换,开销小。同时由于自旋锁是忙轮询,而不是阻塞线程,因此线程的竞争较小。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/f29c52902480eff309f92c0df49fcfac.png)
💗 2.5.2 信号量与互斥锁的底层原理
在linux中,信号量和互斥锁的实现都是基于
Futex
的系统调用来实现的。
Futex
为快速用户控件互斥体,由一块能够被多个进程共享的内存空间(一个对齐后的整型变量)组成,这个整型变量的值通过汇编语言调用CPU提供的
原子操作指令来增加或减少。只有当操作结果不一致时需要进入操作系统内核空间来仲裁,因此绝大多数操作都可以在应用程序空间执行。
2.5 协程
协程是一个特殊的函数,该函数可以在某个地方挂起,且可以重新从挂起的地方继续运行。一个进程可以包含多个线程,一个线程可以包含多个协程。但一个线程中的多个协程是串行的。
协程不是由操作系统管理的,而是由用户进行管理,完全由程序控制,因此协程是在用户态执行的。在协程发生上下文切换时,协程会将当前状态保存到用户栈中。
3、死锁
3.1 资源循环及死锁的产生
一个计算机的资源是有限的,多个进程在计算机中会对资源产生竞争,如果一个进程集合中每个进程都在等待只能有该进程集合中的其他进程才能引发的事件,那么,该进程集合就是死锁。通俗来说,死锁就是多进程/线程竞争资源而造成的一种僵局,在无外界干扰下,无法继续前进。如下图所示:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/e25219a8fc8c259b90aca9b31ed826b5.png)
Q5. 死锁产生的条件 ?
死锁的产生有四个必要的条件, 只有当4个条件同时发生时,才会发生死锁:
① 互斥条件 – 每个资源要么已经分配,要么是可用的。
② 请求和等待条件 – 已经得到了某个资源的进程可以再请求新的资源,且对已占有的资源保持不放。
③ 不可剥夺条件 – 已经分配给一个进程资源不能强制被抢占,只能显式的释放。
④ 环路等待条件 – 死锁发生时,系统存在进程环路。
3.2 死锁问题解决
Q6. 如何避免和消除死锁问题 ?
为了避免和消除死锁,有4个策略:
① 忽略该问题
② 检测死锁并恢复
③ 对资源进行分配,避免死锁
④ 破坏引起死锁的的四个条件之一。
针对4个策略,提出了解决死锁的方法:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/51cb5cee8b5de5dcb922b6b70aeba9d2.png)
💗 3.2.1 死锁检测
①. 每种类型一个资源的死锁检测 --基于树的资源死锁检测
依次将每一个节点作为一棵数的根节点,并进行深度优先搜索,如果碰到已经遇到的节点,那么就发生了死锁。如果任何节点都被穷举了,那么回溯到前面的节点,如果不包含任何环,则说明没有死锁发生。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/6995c0741bea3e442a0f8a6716a40f19.png)
②. 每种类型多个资源的死锁检测 – 基于向量的资源死锁检测
E表示现有资源向量,A表示可用资源向量,C表示当前分配矩阵,R表示请求矩阵。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/d67c7d4cb6b9a6c082e2b1838e7f2b2d.png)
多个资源的死锁检测实例如下:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/0565ea2b5081897b8c636873354e0a0c.png)
💗 3.2.2 死锁恢复
① 利用抢占恢复:将某一资源从一个进程抢占,分配给另一个进程使用。
② 利用回滚恢复:周期性对进程设立检查点检查,当发生死锁时,将进程恢复到更早的检查点,并重新分配资源。
③ 通过杀死进程恢复:杀掉环中的一个进程或者选择环外的一个进程,释放其进程资源。
💗 3.2.3 死锁避免
要避免死锁就要保证资源的分配处于安全状态。 安全状态就是当所有进程突然请求对资源的最大需求,仍然存在某种调度能够使每一个进程运行完毕。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/3fd2b5a0b2e4c34ccb31a7f88a535c01.png)
① 死锁避免算法 - 银行家算法
银行家算法是一种避免死锁的算法。在避免死锁方法中允许进程动态地申请资源,但系统在进行资源分配之前,应先计算此次分配资源的安全性,若分配不会导致系统进入不安全状态,则分配,否则等待。
● 银行家算法的数据结构设计:
为了实现银行家算法,在系统中必须设置这样四个数据结构,分别用来描述系统中可利用的资源(Available)
、所有进程对资源的最大需求(Max)
、系统中的资源分配(Allocation)
,以及进程还需要多少资源(Need)
的情况。
Available
:其初始值是系统中所配置的该类全部可用资源的数目,其数值随该类资源的分配和回收而动态地改变。如果Available[j] = K
,则表示系统中现有j
类资源K
个。
Max
:系统中n
个进程中的每个进程对m
类资源的最大需求。如果Max[i,j] = K
,则表示进程i
需要j
类资源的最大数目为K
。
Allocation
:系统中每一类资源当前已分配给每一进程的资源数。如果 Allocation[i,j]= K
,则表示进程i
当前己分配j
类资源的数目为K
。
Need
:用以表示每一个进程尚需的各类资源数。如果Need[i,j] = K
,则表示进程i
还需要j
类资源K
个才能执行。
● 银行家算法的思路:
设Request(i)
为进程i的请求,如果Requst(i)[j] = K
,表示进程i
需要K
个j
类型的资源。
💗 2.2.4 死锁预防
① 破坏“不可剥夺”条件:一个进程不能获得所需要的全部资源时便处于等待状态,等待期间他占有的资源将被隐式的释放重新加入到 系统的资源列表中,可以被其他的进程使用。
② 破坏”请求与保持条件“:只要有一个资源得不到分配,也不给这个进程分配其他的资源。
③ 破坏“循环等待”条件:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源。
4、内存管理
内存是计算机中的重要资源,是磁盘与CPU进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的。内存的作用是用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。操作系统就会把需要运算的数据从内存调到CPU中进行运算,当运算完成后CPU再将结果传送出来。现在计算机的内存解决方式是分层存储器体系,如下图所示:
在内存管理中,主要要解决三个问题:
① 内存地址的查找 – 虚拟地址空间
② 内存扩展问题 – 虚拟内存
③ 内存置换技术
④ 内存分配与回收
4.1 内存地址的查找 – 虚拟地址空间
在无抽象的或者无操作系统的内存管理中,内存地址查找的是绝对地址物理地址,但绝对地址会影响操作系统的运行。如在单片机中,CPU直接访问内存物理地址,因此在单片机中,无法同时运行多个程序,多个程序会将保存在内存的数据覆盖导致程序崩溃。因此提出了抽象内存管理 — 虚拟地址空间。虚拟地址空间是一个进程可用于寻址内存的一套地址集合,每个进程都有一个自己的地址空间。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/88c46516c10cb1255ae3521fed73fc04.png)
Q1. 逻辑地址,线性地址和物理地址的关系 ?
① 逻辑地址:逻辑地址就是虚拟地址,是指由程序产生的与段相关的偏移地址部分。它是相对于你当前进程数据段的地址,和绝对物理地址没有关系。在应用开发过程中,程序员仅能对逻辑地址进行操作。例如,在进行C语言指针中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址。逻辑地址是相对于程序应用而言的,如果从程序的角度来看,得到的是逻辑地址。
② 线性地址:是逻辑地址到物理地址变换之间的中间层,线性地址=段基地址+段中偏移地址
,如果系统只采用内存分段机制,则线性地址就是物理地址,如果系统采用段页机制,则线性地址再通过页表转换得到物理地址。线性地址是相对于CPU而言的,即在CPU计算过程中使用的是线性地址。因此,如果从CPU的角度来看,得到的是线性地址。
③ 物理地址:是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是真实的内存地址,也是程序代码、数据实际在内存中被加载的地址。物理地址是相对于硬件来说的如果从硬件的角度来看,得到的是物理地址。
4.2 虚拟地址空间与物理地址之间的关系(分段、分页)
虚拟地址与物理地址的映射通常有两种方式:内存分段与内存分页。
💗 4.2.1 内存分段
内存分段是指将内存分为若干个逻辑段,不同的段表示不同的性质,如代码段,数据段,栈段,堆段,BSS段。
Q1. 为什么进行内存分段 ?
如果没有内存分段,则会有以下问题:
● 程序会直接访问内存的物理地址,如果有多个程序运行时,则交叉的地址会造成数据错误。
● 内存中的系统程序无法受到保护,各个程序的内存无法相互隔离。
Q2. 内存分段带来的问题 ?
① 内存碎片问题:
● 外部内存碎片: 外部内存碎片位于任何已分配区域或页面外部的空闲存储块,由于内存中剩余内存太小,无法申请内存空间给新进程使用。 此时的内存还没有被分配出去(不属于任何进程),则剩余的内存就称为外部内存碎片。
● 内部内存碎片:内部内存碎片就是已经分配出去,却不能被利用的内存空间。进程的所有内存都被装载到物理内存中,但进程中有部分页面不是经常被使用的,且在进程占有该内存时,系统无法利用它,这就导致了物理内存的浪费。
② 内存交换问题:
通过内存交换可以解决内存分段的内存碎片问题,但内存分段的内存交换效率很低,这是因为,内存交换的是一个占用内存空间很大的程序,每次内存交换,需要把一大段的连续内存数据写入到磁盘,由于硬盘的访问速度远低于内存,这就会造成计算机额卡顿。
💗 4.2.2 内存分页
内存分页利用了程序的局部性原理。分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(page)。在 Linux 下,每一页的大小为 4KB
。采用了分页,那么释放的内存都是以页为单位释放的,不会产生无法给进程使用的小内存。
每个程序拥有自己的地址空间,这个空间被分割成多个块,每一块称为页面,每一页有连续的地址范围,这些页被映射到物理内存。若该页面没有被映射,则产生缺页中断。虚拟地址空间按照固定的大小划分成页面若干个单元,在物理内存中对应的单元称为叶框,页面和叶框的大小通常是一样的。 如下图所示:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/2e7f6bd08719ab4b008e7d4ca1408a61.png)
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/c5e0aa9e55221217da9301689c6cb11d.png)
4.3 内存扩展技术 – 虚拟内存
虚拟内存是计算机内存管理的技术,是一种逻辑上扩充物理内存的技术。虚拟内存利用自动覆盖和交换技术,将硬盘的一部分作为内存使用,同时将内存作为磁盘空间上的地址空间缓存,主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据。
Q1. 为什么需要虚拟内存 (虚拟内存有什么作用)?
虚拟内存提供了三个能力:缓存,内存管理,内存保护
▶ 物理地址空间缓存
虚拟内存作为主存的缓存工具,它将主存看成是一个存储在磁盘空间上的地址空间的高速缓存,主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据。在虚拟内存中会包含三个互斥的子集:未分配页面,缓存页面,未缓存页面。
▶ 内存管理
通过虚拟内存,操作系统可以为每个进程提供独立的虚拟地址空间,简化了进程的加载,链接,数据共享以及程序的内存分配。
▶ 内存保护
虚拟内存保护了每个进程的地址空间不被其他进程破坏。当出现内存的错误时,会报告段错误的异常。
① 不允许一个用户进程修改它的只读代码段;
② 不允许用户进程读或修改任何内核中的代码和数据结构;
③ 不允许用户进程读或写其他进程的私有内存;
④ 不允许用户进程修改任何其他进程共享的虚拟页表
Q2. 虚拟内存的优点和缺点 ?
优点:虚拟内存可以弥补物理内存大小的不足,一定程度的提高反映速度;减少对物理内存的读取从而保护内存延长内存使用寿命;
缺点:占用一定的物理硬盘空间;加大了对硬盘的读写;设置不得当会影响整机稳定性与速度。
4.4 内存(页面)置换问题
💗 4.4.1 内存的置换方式
内存的置换可以分为两种方式:① 局部页面置换;②全局页面置换。全局页面置换通常比局部页面置换要好。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/f0263ae46877c3c751c8c9c2da8b679a.png)
💗 4.4.2 内存的置换算法
当虚拟内存发生缺页中断时,需要在内存中选择一个页面(长期未被防问和使用的页面)将其换出内存,为即将调入的页面腾出空间。置换算法的基本原则是:尽可能保留经常被访问的页面,置换掉长期未使用的页面,从而减少置换的次数,提高页面的命中率。页面置换常用页面置换算法如下:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/c28916e6f758e88a3bd38898e0cb3365.png)
4.5 内存分配与回收算法
内存的分配与回收是内存管理里重点,由于在内存分配,使用和释放过程中会产生内存碎片问题,因此就需要特定的内存管理算法来减少内存碎片的产生,提高系统性能。
内存的分配和回收分为两个体系:页管理和内核对象管理。内存分配架构如下图所示:
● 页管理结构:由冷热缓存,buddy system
组成,负责大于一页大小的内存页的缓存,分配和回收。
● 内核对象结构:有per-cpu
高速缓存,slab
分配器,buddy system
组成,负责内核对象(小于一页大小的内存块)的缓存,分配和回收。
💗 4.5.1 伙伴系统 buddy system
buddy system
算法主要用来解决外部碎片问题。把所有的空闲页框分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续的页框。每个块的第一个页框的物理地址是该块大小的整数倍,例如,大小为16个页框的块,其起始地址是16*2^12的倍数(每个页框的大小为4KB)。buddy system
最多一次分配4MB(1024×4KB)的内存空间,最少一次分配4KB(1×4KB)的内存空间。
③ 第一个块的第一个页框的物理地址是
2
×
b
×
2
12
2×b×2^{12}
2×b×212;
1. buddy system 内存分配过程
以分配16KB空间为例,分配过程如下图所示:
2. buddy system 内存释放过程
释放是申请的逆过程,也可以看作是伙伴的合并过程。当释放一个内存块时,先在其对于的free_area
链表中查找是否有伙伴存在,如果没有伙伴块,直接将释放的块插入链表头。如果有或板块的存在,则将其从链表摘下,合并成一个大块,然后继续查找合并后的块在更大一级链表中是否有伙伴的存在,直至不能合并或者已经合并至最大块2^10
为止。
💗 4.5.2 冷热缓存 per-cpu pageset
冷热缓存是针对CPU缓存而言的,由冷热页作为基本单位。
● 冷页:该空闲页不在CPU缓存当中。
● 热页:该空闲页仍在高速缓存中。
Q1. 冷热页的作用 ?
① 由于内存访问既可能从CPU进行访问,也可能从DMA进行访问,且DMA访问时不会经过CPU缓存,因此在内存分配时,CPU访问分配热页,DMA访问分配冷页。
② Buddy System
在给某个进程分配某个zone
中空闲页的时候,首先需要用自旋锁锁住该zone
,然后分配页。如果多个CPU上的进程同时进行分配页,便会竞争。引入了per-cpu pageset
后,当多个CPU上的进程同时分配页的时候,竞争便不会发生,提高了效率。
③ 当释放单个页面时,空闲页首先回到per-cpu pageset
中,减少了zone
中z自旋锁的使用,当页面缓存中页面数量超过阈值时,再将页面放回到伙伴系统中。
💗 4.5.3 slab内存分配器
buddy System
是以页框为基本单位的,最小的分配内存是1个页框(4KB),当内核申请小块内存时,如几个字节或几百个字节时,如果采用buddy System
分配内存就会产生大量的内部碎片。为了解决小块内存的分配问题,Linux实现了SLAB
内存分配器,SLAB
分配器是建立在buddy system
之上的。
1. SLAB分配原理
SLAB
将内核中的数据结构看做对象(如进程对象task_struct
,管道对象pipe
,文件对象file
,文件映射对象mmap
等),数据结构进行创建和撤销看做对象的构造和析构。它的基本思想是将内核中经常使用的对象放到高速缓存中(如经常创建进程或线程),并且由系统保持为初始的可利用状态。当对数据进行申请和释放时,内核会直接从slab
分配器的高速缓存中获取一个已经初始化了的对象;当进程结束时,该结构所占的页框并不被释放,而是重新返回SLAB
分配器中。
SLAB
分配器有以下三个基本目标:
①.减少伙伴算法buddy system
在分配小块连续内存时所产生的内部碎片;
②.将频繁使用的对象缓存起来,减少分配、初始化和释放对象的时间开销。
③.通过着色技术调整对象以更好的使用硬件高速缓存;
2. SLAB分配原理
SLAB
分配器为每种对象分配一个per-cpu
高速缓存,这个缓存可以看做是同类型对象的一种储备。每个高速缓存所占的内存区又被划分多个SLAB
,每个SLAB
是由一个或多个连续的页框组成。每个页框中包含若干个对象,既有已经分配的对象,也包含空闲的对象。
4.6 内存(页面)设计中的其他问题
除去内存置换的问题,在内存设计中,内存的负载控制,页面的大小等都是影响内存性能的因素。
① 内存的负载控制:当出现内存颠簸时,可将一部分进程交换到磁盘中,减少竞争内存的进程数。
② 页面大小:
– 当内存中有存在内部碎片(已经被分配的内存,但是无法被使用,即数据不会恰好装满每个页面,在最后一个页面中有一半是空的)时,选择小页面较好。
– 但当CPU进行进程切换时,页面越小,装入页面寄存器的时间越长,且页表的占用空间也越大。
假设进程平均大小为
s
s
s个字节,页面大小是
p
p
p个字节,每个页表项需要
e
e
e个字节,则每个进程需要的页数为
s
/
p
s/p
s/p,占用了
s
e
/
p
se/p
se/p个字节的页表空间,内部碎片在最后一个页浪费的内存是
p
/
2
p/2
p/2,此时由页表和内部碎片的开销为
s
e
/
p
+
p
/
2
se/p+p/2
se/p+p/2,最优的页面大小为
p
=
2
s
e
p=\sqrt {2se}
p=2se
5、文件系统
Linux系统中,把一切资源都看作是文件,包括硬件设备。文件系统的框架如下:
5.1 虚拟文件系统 VFS
由于存在很多不同物理介质的文件系统,且具有不同的接口,因此就需要VFS
。VFS
的采用标准Unix系统调用,使open()
,read()
,write()
等函数调用不用关心底层的存储介质以及文件系统类型。通过VFS层
屏蔽了底层文件系统和物理介质的差异性。
VFS
为了向用户层提供统一的接口,需要高度的抽象,这也是VFS
的核心 ----- 统一文件模型
💗 5.1.1 文件file与inode
在操作系统中,文件是对磁盘的抽象,是进程创建的信息逻辑单元。文件存储在硬盘上,硬盘的最小存储单位是扇区,每个扇区为512字节。由多个扇区组成“块”,是文件存取的最小单位。
一个文件=文件数据+索引节点(inode)
,文件数据存储在“块”中,每个文件都有对应的inode
,inode
包含文件系统处理文件的所有信息。在操作系统内部不是使用文件名来识别文件,而是通过inode号来识别文件,文件名只是inode号的别称。每个inode
节点大小为128字节或256字节。操作系统将硬盘分成两个区域,一个数据区,存放文件数据。另一个是inode
区,存放inode
包含的文件元信息。
注意:因为每个文件都必须有一个inode
,当磁盘中inode
区空间不足时,即使磁盘还未存满,也无法创建新的文件。
💗 5.1.2 目录与inode
目录也是一种文件,打开目录就是打开目录文件。目录的结构主要为层次目录结构,每一个目录项=目录中包含的文件名+该文件对应的inode
。目录文件的读权限(r)
和写权限(w)
,都是针对目录文件本身。由于目录文件内只有文件名和inode
,所以如果只有读权限(r)
,只能获取文件名,无法获取其他信息,因为其他信息都储存在inode
节点中,而读取inode
节点内的信息需要目录文件的执行权限(x)
。
💗 5.1.3 链接与inode
文件链接是操作系统的一种文件共享方式,链接分为硬链接和软链接。
1. 硬链接 B =>A (A是B的硬链接)
硬链接是指链接的两个文件的inode节点号相同,即inodeA=inodeB
,一个inode
节点对应两个不同的文件名,两个文件名指向同一个文件。如果删除其中一个文件,对另一个文件没有影响。在链接时,每链接一个文件名,inode
节点上的链接数增加1,每删除一个文件名,inode
节点上的链接数减1,当链接数为0时,inode
节点和对应数据块被回收 (引用计数机制)。
ln 源文件 链接名 //源文件路径要写绝对地址
2. 软链接(符号链接) B =>A (A是B的软链接)
软链接是指链接的两个文件的inode
节点号不同,inodeA≠nodeB
,A,B两个文件指向两块不同的数据块,在A的数据块中存放的是B文件的路径名,A和B之间是主从关系。当B被删除时,A文件仍然存在,但指向的是一个无效的链接。
ln -s 源文件 链接名 //源文件路径要写绝对地址
Q1. 硬链接与软链接区别 ?
① 硬链接:
● 不能对目录创建硬链接,主要原因是文件系统不能存在链接环,否则会导致文件遍历操作混乱。
● 不能对不同的文件系统创建硬链接。
● 不能对不存在的文件创建硬链接。
② 软链接:
● 可以对目录创建软链接,遍历操作会忽略目录的软链接。
● 可以跨文件系统
● 可以对不存在的文件创建软链接。
5.2 inode的特殊作用
① 当文件名中包含特殊字符,导致文件无法正常删除时,可以通过删除inode节点来删除文件。
② 当移动,重命名文件时,只改变文件名,不影响inode号。
③ 打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名。