操作系统

272 阅读27分钟

什么是系统调用

处理器设有两种模式:“用户模式”与“内核模式”。

一些诸如修改基址寄存器内容的指令只有在内核模式中可以执行。 同样,为了安全问题,一些I/O操作的指令都被限制在只有内核模式可以执行,因此操作系统有必要提供接口来为应用程序提供诸如读取磁盘某位置的数据的接口,这些接口就被称为系统调用。

  • 用户态: 用户态运行的进程或可以直接读取用户程序的数据。
  • 系统态: 系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。

这些系统调用按功能大致可分为如下几类:

  • 设备管理。完成设备的请求或释放,以及设备启动等功能。
  • 文件管理。完成文件的读、写、创建及删除等功能。 open 打开文件creat 创建新文件
  • 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。 fork 创建一个新进程
  • 进程通信。完成进程之间的消息传递或信号传递等功能。 ipc 进程间通信总控制调用
  • 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。

系统调用和库函数的区别

image.png

中断和异常

引入中断机制,可以实现多道程序并发执行。

image.png

进程与线程

什么是进程

  • 进程实体(静态的):程序段 + 数据段 + PCB(进程控制块)。创建进程和撤销进程指的是PCB的创建和撤销。

  • 进程(动态的),是一个进程实体运行的过程。 进程控制块PCB中,包含一下数据:

  • 进程标识符PID

  • 用户标识符UID

  • 状态

  • 优先级

  • 程序计数器 : 程序中即将执行的下一条指令的地址

  • 内存指针

  • 上下文数据

  • I/O状态信息

进程的组织方式:

组织的是PCB

  • 链接方式,操作系统持有多个指针,指向不同队列 image.png
  • 索引方式,操作系统持有多个指针,指向不同的索引表

image.png

什么是线程

线程是轻量级进程,它的资源所有权的单位是进程或任务。

  • 用户线程 : 使用函数库创建,内核不感知
  • 内核线程:操作系统内核的线程,是处理机分配的单位。

image.png

多线程模型

  • 一对一

image.png

  • 多对一

image.png

  • 多对多

image.png

(3) 区别

进程是资源分配的最小单位,线程是CPU调度的最小单位。 每个进程所能访问的内存都是圈好的。一人一份,谁也不干扰谁。进程需要管理好它的资源。其中,线程作为进程的一部分,扮演的角色就是怎么利用中央处理器去运行代码。这其中牵扯到的最重要资源的是中央处理器和其中的寄存器,和线程的栈(stack)。线程关注的是中央处理器的运行,而不是内存等资源的管理。

进程有完整的资源平台,而线程只独享指令执行流的必要资源,比如寄存器或栈(暂时存放数据和地址,保护现场和断点)。

线程比进程减少了时间和空间开销。

进程的状态

状态

  • 创建状态(new) :进程正在被创建,尚未到就绪状态。
  • 就绪状态(ready) :进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
  • 运行状态(running) :进程正在处理器上上运行(单核 CPU 下任意时刻只有一个进程处于运行状态,OS是分成很小的时间片进行任务调度。每个时间片只能执行一个进程的代码。但由于时间片切换得很快,在宏观感觉就是同时在执行的。)。
  • 阻塞状态(waiting) :又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
  • 结束状态(terminated) :进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。

状态转化

留意一下:OSP77

  • 运行->就绪
  • 运行->阻塞
  • 就绪->退出

进程间通信的方式

1. 管道

(1)PIPE,匿名管道

  • FIFO的队列,一个进程写,另一个进程读,强制互斥,只有一个进程能访问。 数据只能向一个方向流动,写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据。
  • 管道对于管道两端的进程而言,就是一个文件,只存在与内存中。管道的实质是一个内核缓冲区,一个数据只能被读一次,读出来以后在缓冲区就不复存在了。
  • 只能用于具有亲缘关系的进程之间,父子进程。
  • 管道的缓冲区是有限的,在创建管道时会指定一个字节数,如果没有足够的空间来写入数据,该进程会被阻塞。
  • 匿名管道是只存在于内存中的文件
  • shell中的 netstat -tulnp | grep 8080 ”|“是管道的意思,它的作用就是把前一条命令的输出作为后一条命令的输入。
匿名管道通信原理

编程步骤:示例

  • Linux中使用pipe()函数创建一个匿名管道,
  • 使用fork() 创建一个子进程,父进程中返回子进程ID,子进程中返回0
  • 使用文件I/O函数read()和write()读管道进行读写
  • 使用close()函数关闭管道两端。
int pipe(int fd[2]);
参数fd[2]是一个长度为2的文件描述符数组,fd[1]是写入端的文件描述符,fd[0]是读出端的文件描述符。

(2)FIFO,命名管道

  • 有名管道提供了一个路径名与之关联,非亲缘的进程能够彼此通过有名管道相互通信,只要可以访问该路径。
  • 命名管道也是个文件,存在于实际的磁盘介质或者文件系统
  • shell中 mkfifo test //创建命名管道;echo "this is a pipe" > test // 写数据;cat < test // 读数据
命名管道通信原理

编程步骤:示例

  • Linux中使用mkfifo()函数创建一个匿名管道,
  • 使用I/O函数的open()、close()对FIFO进行打开关闭操作
  • 使用文件I/O函数read()和write()读管道进行读写操作
  • unlink(filename)来删除FIFO
int mkfifo(const char *pathname, mode_t mode);
创建成功返回0,出错返回1。函数第一个参数为普通的路径名,即创建后FIFO文件的名字,第二个参数与打开普通文件的open函数中的mode参数相同。

2. 信号

  • 信号是用于像一个进程通知发生异步事件的机制,进程可以通过执行某些行为或者忽略该信号来进行回应。
  • 进程间可以相互发信号,内核也可以在内部发送信号。
  • 信号的传递是通过修改信号要发送到的进程所对应的进程表中的一个域来完成。
  • 只有在进程在运行,才会处理信号,未处于执行状态,则该信号就有内核保存起来,知道该进程回复执行并传递给它。
  • 信号来源
    • 硬件来源:用户按键输入Ctrl+C退出、硬件异常如无效的存储访问等。
    • 软件终止:终止进程信号、其他进程调用kill函数。
  • 常见信号:
SIGINT:程序终止信号。程序运行过程中,按Ctrl+C键将产生该信号。
SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\\键将产生该信号。
SIGKILL:用户终止进程执行信号。shell下执行kill -9发送该信号。
SIGFPE:运算中出现致命错误,如除零操作、数据溢出等。

3. 消息队列

  • 消息队列是存放在内核中的消息链表,结构如下

  • 消息队列跟随内核,只有在内核重启(OS重启)或者显示调用函数销毁,该消息队列才会被真正的删除。匿名管道是跟随进程的,进程结束之后,匿名管道就死了。

  • 消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。管道:进程给 b 进程传输数据,只能等待 b 进程取了数据之后 a 进程才能返回,消息队列不需要。

消息队列的通信原理

  • 编程步骤:

    • 使用ftok()生成key
    • 使用msgget()创建/获取消息队列,返回值为队列标识符
    • 发送消息msgsnd()/接收消息msgrcv()
    • 消息队列属性与删除msgctl()
  • msgget()函数用于创建或打开一个消息队列

int msgget(key_t key, int msgflg);
key是消息队列具有一个唯一的键值,使用ftok()函数
msgflg是一些标志位,可以取IPC_CREAT、IPC_EXCL、IPC_NOWAIT
key_t ftok(char *pathname, char proj);
参数pathname为一任意存在的路径名,
参数proj为1~255之间的任一数字,
ftok根据路径名,提取文件信息,再根据这些文件信息及proj的值合成key

文件的路径,名称和子序列号不变,那么得到的key值永远就不会变? 文件可能被删除,生成的key会变。
所以要确保key值不变,要么确保ftok()的文件不被删除,要么不用ftok(),指定一个固定的key值
  • 消息队列传递的消息由两部分组成,包括消息类型和所传的数据,对于发送端,首先预置一个这样的msgbuf缓冲区并写入消息类型和内容,然后调用相应的发送函数;对于接收端,首先分配一个msgbuf缓冲区,然后把消息读入缓冲区即可。
struct msgbuf
{
    long msgtype; //消息类型
    char msgtext[1024]; //所传的数据
};
  • 向消息队列发送数据使用msgsnd()函数,发送的一个消息数据会被添加到队列的末尾,
int msgsnd(int msqid, const void *prt, size_t nbytes, int flags);
参数msqid为消息队列的引用标识符(ID),
参数prt为void型指针,指向要发送到的消息,
参数nbytes为发送的消息的字节长度,
参数flag用于指定消息队列满时的处理方法
	如果flags标志为IPC_NOWAIT,在消息队列没有足够的空间容纳要发送的数据时,则msgsnd()函数立刻出错返回,
    	标志不为IPC_NOWAIT,发送消息的进程被阻塞,直至消息队列有空间或队列被删除时返回。
  • 从消息队列接收数据使用msgrcv()函数,函数原型如下:
int msgrcv(int msqid, const void *prt, size_t nbytes, long type, int flags);
参数type表示接收的数据类型:
	type = 0, 接收队列中第一条消息
    	type > 0, 接收队列中数据类型为type的第一条消息
    
参数flag用于指定消息队列满时的处理方法
	IPC_NOWAIT,如果MQ中没有满足条件的信息,立即返回。
    	IPC_EXCEPT, 返回MQ中第一个不为type数据类型的消息。
  • 消息队列属性设置 msgctl()函数
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
函数msgctl()将对参数msqid标识的消息队列执行参数cmd所指的命令,包括3种命令
    IPC_STAT:用于获取消息队列信息,返回的信息存贮在参数buf中
    IPC_SET:用于设置消息队列的属性,要设置的属性存储在参数buf中
    PC_RMID:删除msqid标识的消息队列

4. 共享内存

  • 内核专门留出了一块内存区,使得多个进程可以可以直接读写同一块内存空间而不需要进行数据的拷贝,是最快的。
  • 由于多个进程共享一段内存,因此需要依靠某种同步机制(如信号量)来达到进程间的同步及互斥

共享内存通信原理

  • 编程步骤:示例
    • 生成key,ftok()
    • 使用key创建/获得一个共享内存,shmget()
    • 映射共享内存,得到虚拟地址,shmat()
    • 使用共享内存,通过地址指针
    • 移除映射,shmdt()
    • 销毁共享内存,shmctl()
  • 创建共享内存, 用到**shmget()**函数
int shmget(key_t key, int size, int flag);
参数key为共享内存的键值(ftok()生成一个),参数size为创建共享内存的大小,参数flag为调用函数的操作类型。参数key和参数flag共同决定的shmget()的作用:
    key为IPC_PRIVATE时,创建一个新的共享内存,flag取值无效。
    key不为IPC_PRIVATE,且flag设置了IPC_CREAT位,而没有设置IPC_EXCL位时,如果key为内核中的已存在的共享内存键值,则打开,否则创建一个新的共享内存。
    key不为IPC_PRIVATE,且flag设置了IPC_CREAT和IPC_EXCL位时,则只执行创建共享内存操作。如果key为内核中的已存在的共享内存键值,返回EEXIST错误。
  • 对于每一个共享内存段,内核会为其维护一个shmid_ds类型的结构体:
struct shmid_ds
  {
    struct ipc_perm shm_perm;           /* operation permission struct */
    size_t shm_segsz;                   /* size of segment in bytes */
    __time_t shm_atime;                 /* time of last shmat() */
#ifndef __x86_64__
    unsigned long int __glibc_reserved1;
#endif
    __time_t shm_dtime;                 /* time of last shmdt() */
#ifndef __x86_64__
    unsigned long int __glibc_reserved2;
#endif
    __time_t shm_ctime;                 /* time of last change by shmctl() */
#ifndef __x86_64__
    unsigned long int __glibc_reserved3;
#endif
    __pid_t shm_cpid;                   /* pid of creator */
    __pid_t shm_lpid;                   /* pid of last shmop */
    shmatt_t shm_nattch;                /* number of current attaches */
    __syscall_ulong_t __glibc_reserved4;
    __syscall_ulong_t __glibc_reserved5;
  };
  • 共享内存的附加(映射),创建一个共享内存后,某个进程若想使用,需要将此内存区域附加(attach)到自己的进程空间(或称地址映射),需要用到shmat()函数, shmat()函数执行成功后,会将shmid的共享内存段的shmid_ds结构的shm_nattch计数器值加1。
int *shmat(int shmid, const void *addr, int flag);
运行成功返回指向共享内存段的地址指针,出错返回-1。
参数shmid为共享内存的ID,
参数addr和参数flag共同说明要引入的地址值,通常只有2种用法:
    addr为0,表明让内核来决定第1个可引用的位置
    addr非0,且flag中指定SHM_RND,则此段引入到addr所指向的位置。
  • 共享内存的分离,当进程使用完共享内存后,需要将共享内存从其进程空间中去除(detach),使用shmdt()函数,shmdt()函数执行成功后,shm_nattch计数器值减1。
int shmdt(void *addr);
参数addr是调用shmat()函数的返回值,即共享内存段的地址指针
  • 共享内存的控制, 使用**shmctl()可以对共享内存段进行多种控制操作,包括删除
int shmctl(int shmid, int cmd, struct shmid_s *buf);
参数shmid为共享内存的ID,参数cmd指明了所要进行的操作,与参数*buf配合使用
    cmd == IPC_STAT, 取shmid指向的共享内存的shmid_ds结构,对参数buf指向的结构赋值
    cmd == IPC_RMID, 当shm_nattch为0时,删除shmid所指向的共享内存段

5. 信号量

  • 信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步,它通常是1。
  • 当有进程要求使用共享资源时,系统首先要检测该资源的信号量,
    • 1.若该资源的信号量值大于0,则进程可以使用该资源,此时,进程将该资源的信号量值减1;
    • 2.若该资源的信号量值<=0,则进程阻塞,直到信号量值大于0时进程被唤醒,访问该资源;
    • 3.当进程不再使用由一个信号量控制的共享资源时,该信号量值增加1,如果此时有进程处于休眠状态等待此信号量,则该进程会被唤醒。
  • PV操作的详细实现:
信号量S:S>=0时表示某资源的可用数,s<0时其绝对值表示阻塞队列中等待该资源的进程数。

	P操作表示申请一个资源
	P操作的定义:S=S-1,若S>=0,则执行P操作的进程继续执行;若S<0,则置该进程为阻塞状态,并将其插入阻塞队列

	V操作表示释放一个资源
	V操作定义:S=S+1,若S>0则执行V操作的进程继续执行;若S<0,则从阻塞状态唤醒一个进程,并将其插入就绪队列,执行V操作的进程继续执行。

6. Socket

与其它通信机制不同的是,它可用于不同机器间的进程通信。 具体的zhuanlan.zhihu.com/p/143555322

  • 服务器先用socket()函数来建立一个套接字,用这个套接字完成通信的监听及数据的收发。
  • 服务器用bind()函数来绑定一个端口号和IP地址,使套接字与指定的端口号和IP地址相关联。
  • 服务器调用listen()函数,使服务器的这个端口和IP处于监听状态,等待网络中某一客户机的连接请求。
  • 客户机用socket()函数建立一个套接字,设定远程IP和端口。
  • 客户机调用connect()函数连接远程计算机指定的端口。
  • 服务器调用accept()函数来接受远程计算机的连接请求,建立起与客户机之间的通信连接。
  • 建立连接以后,客户机用write()函数(或close()函数)向socket中写入数据,也可以用read()函数(或recv()函数)读取服务器发来的数据。
  • 服务器用read()函数(或recv()函数)读取客户机发来的数据,也可以用write()函数(或send()函数)来发送数据。
  • 完成通信以后,使用close()函数关闭socket连接。

线程间同步的方法

1. 信号量

OS P139

  • 信号量分为单值和多值两种,前者只能被一个进程获得,后者可以被若干个线程获得。
  • 用PV 操作(semWait,semSignal操作)实现对临界区的控制,程序对信号量的操作都是原子操作。

(1)计数型信号量(counting semaphores)

在代码中规定好的P,V区间内,一个临界资源可以被多个线程访问 原语定义:

struct binary_semaphore
{
	int count;
	queueType queue;
};

//申请使用资源,Passeren操作
void semWait(binary_semaphore s) {
	s.count--;
	if (s.count < 0) {
		// 把当前进程插入等待队列
		// 阻塞当前进程
	}
}

//释放资源,Vrijgeven操作
void semSignal(binary_semaphore s) {
	s.count++;
	if (s.count <= 0) {
		// 把阻塞的进程P从等待队列中移出
		// 把P置为就绪状态
	}
}

(2)二元信号量(binary semaphore)

本质上是信号量,为了解决互斥问题专门优化得来。

原语定义:

struct binary_semaphore
{
	enum {0, 1} value;
	queueType queue;
};
//申请使用一个资源,Passeren操作
void semWait(binary_semaphore s) {
	if (s.value == 1) {
		s.value = 0;
	} else {
		// 把当前进程插入等待队列
		// 阻塞当前进程
	}
}
//释放一个资源,Vrijgeven操作
void semSignal(binary_semaphore s) {
	if (s.queue == empty) {
		s.value = 1;
	} else {
		// 把阻塞的进程P从等待队列中移出
		// 把P置为就绪状态
	}
}

区分:互斥量/互斥锁(mutexs,mutual exclusion semaphores)

区别:一个线程获得一个互斥锁后,其余线程均不能释放该锁,只能由获取锁的线程释放锁。但是二元信号量可以做到一个线程获取一个锁,其他线程同样可以释放该锁。

用信号量实现互斥

互斥是指在某个时刻,只能有一个线程访问资源,其他线程无法访问。

/*信号量实现互斥*/
void TaskA() {
  semWait(mutex);
  //临界区代码,读写共享内存
  semSignal(mutex);
}

void TaskB() {
  semWait(mutex);
  //临界区代码,读写共享内存
  semSignal(mutex);
}

用信号量实现同步

同步关心的是代码段能否按照顺序执行,比如下面,使用信号量能够保证先执行X, 后执行N

/*信号量实现同步*/
int mutex = 0; //mutex初始化为0

void TaskA() {
	M
  P(mutex);
  	N
}

void TaskB() {
	X
  V(mutex);
  	Y
}

信号量缺点:

  • 信号量机制必须有公共内存,不能用于分布式操作系统
  • 使用时对信号量的操作分散,而且难以控制,所以引入管程

用信号量解决生产者-消费者问题

用信号量来实现其中的线程互斥、线程同步问题。

问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。

分析:

  • 任何时刻只能有一个线程操作缓冲区

  • 缓冲区满时,生产者必须等待消费者

  • 缓冲区空时,消费者必须等待生产者 解决方法:

  • 因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问

  • 为了同步生产者和消费者的行为,需要使用两个信号量:empty 记录空缓冲区的数量(即剩余坑位数量),full 记录满缓冲区的数量(即剩余物品数量)。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。

    • 假设几次生产之后,坑位满了,生产者线程应被阻塞,唤醒消费者线程;
    • 假设几次获取之后,物品没了,消费者线程应被阻塞,唤醒生产者线程;
int empty = buffer.size(); // 假设一开始缓冲区没东西
int full = 0; // 假设一开始缓冲区没东西
int mutex = 1;
void producer() {
   while(1) {
   	生产一个产品();
   	P(empty);

   	P(mutex);
   	把产品放入缓冲区();
   	V(mutex);
   	
   	V(full);
   }
}

void consumer() {
   while(1) {
   	P(full);

   	P(mutex);
   	从缓冲区取出一个产品();
   	V(mutex);

   	V(empty);
   	使用产品();
   }
}

这里的互斥锁和同步锁不能反过来写,必须先检查缓冲区容量,再申请互斥访问。因为,如果反过来了,·注意这种情况:当一开始buffer为空时,先执行consumer(),

  • P(mutex); // 执行OK,consumer线程拿到互斥锁
  • P(full); // full一开始=0,执行这句同步锁后,full-- < 0, 导致consumer线程被阻塞不往下执行,所以produce线程就绪。但此时consumer还拿着上面P(mutex)得到的互斥锁,由于consumer已经被阻塞无法执行到V(mutex),所以produce线程无法被就绪,造成死锁
void consumer() {
   while(1) {
    	P(mutex); // 执行OK,consumer线程拿到互斥锁
   	P(full); // 由于full一开始=0,执行这句同步锁,full-- < 0, 导致consumer线程被阻塞不往下执行,produce线程就绪。但此时consumer还拿着上面P(mutex)得到的互斥锁,由于consumer已经被阻塞无法执行到V(mutex),所以produce线程无法被就绪,造成死锁。
   	从缓冲区取出一个产品();
   	V(mutex);
   	V(empty);
   	使用产品();
   }
}

2. 管程

  • 局部数据变量只能被管程的过程访问,一个线程调用管程的一个过程进入管程,在任一时刻,只能有一个线程在管程中执行,其余的都被阻塞等待管程可用。

  • 与信号量不同的是,管程中的线程可以临时放弃对临界区的互斥访问

  • 管程通过条件变量支持同步,每个条件变量对应一种等待原因,对应一个等待队列

  • 有两个函数可以操作条件变量:

    • wait() :将当前线程阻塞在等待队列中,唤醒另一个等待者或者释放互斥访问锁
    • signal(): 将等待队列中的一个线程唤醒

条件变量的实现

/*条件变量的实现*/
class Condition {
	int numWaiting = 0;//初始化为0,sema中,初始值为资源数
	WaitQueue q; //等待队列
}

void wait(lock) {
	numWaiting++;
	Add this thread to q;
	Release lock; //放弃对管程的互斥访问权
	schedule(another thread)//执行调度,唤醒其他线程,通过mutex实现
	require lock; // 回来了再申请对管程的访问权限
}

void signal(lock) {
	if (numWaiting > 0) {
		Remove thread T from q;
		wakeup(T);//放到就绪队列中,通过mutex实现
		numWaiting--;
	}
}

用管程解决生产者-消费者问题

class BoundedBuffer {
	Lock lock; //一个锁,一个入口等待队列
	int N = bufferSize; //缓冲区最大容量
	int count = 0; // 缓冲区物品数量计数
	Condition notFull, notEmpty; //两个条件变量,不空和不满

	void producer() {
		while (1) {
			lock.Acquire(); // 进入管程,拿到互斥访问权
			if (count == N) {// 读写之前,先看有没有空地
				notFull.wait();//满了,produce线程wait在notFull条件上, 切到consume线程
			}
			Add thing to buffer;
			count++;
			notEmpty.signal();//不空了,释放条件变量,唤醒等待的线程,如果没有,就空操作
			lock.Release(); // 离开管程
		}
	}


	void consumer() {
		while (1) {
			lock.Acquire(); // // 进入管程,拿到互斥访问权
			if (count == 0) {//读写之前,先看有没有空地
				notEmpty.wait();//空了,consume线程wait在notEmpty条件上,切到produce线程
			}
			Remove thing from buffer;
			count--;
			notFull.signal(); //不满了,释放条件变量,唤醒等待的线程,如果没有,就空操作
			lock.Release(); // 离开管程
		}
	}
}
  • 注意,用管程解决PS问题时,可以先拿互斥锁,再检验容量,是因为管程中的线程可以放弃互斥访问权。信号量不可以。

管程条件变量释放后的处理机制

当条件变量被释放后,管程内部的线程,和正在占用管程的线程,这两个谁有优先执行权?

Hansen管程
  • 正在占用管程的线程有优先权
  • 切换更少,效率更高
  • 不确定性高 如果使用Hansen模式,那么在T1中条件变量的判断需要使用while,因为另一个线程T2在signal()后,还会继续执行到T2管程release(),在执行这段过程的时间中可能有其他线程进入管程,导致variable变化,所以当回到T1后,还需要再次判断variable,所以用while。OS P149
void T1() {
          lock.Acquire(); 
          while (variable) {//
              X.wait();
          } 
          
          ... ...
          
          lock.Release();
	}
Hoare管程
  • 管程内部的线程有优先权
  • 切换多,效率低
  • 确定性高 如果使用Hoare模式,那么在T1中条件变量的判断需要使用if,因为另一个线程T2在发送signal()后,T2立即阻塞(Pascal中要求signal()需作为管程中的最后一个操作),然后管程控制权立即回到T1(建立一个独立的紧急队列实现),继续执行T1, 无需再次判断,所以可以用if。OS P149
void T1() {
          lock.Acquire(); 
          if (variable) {//
              X.wait();
          } 
          
          ... ...
          
          lock.Release();
	}

管程的缺点

  • 管程需要共享内存,如果一个分布式系统具有多个CPU,并且每个CPU拥有自己的私有内存,它们通过一个局域网相连,那么这些原语将失效。消息传递可以解决这个问题。

消息传递

带学习

读者写者问题

哲学家就餐问题

进程调度算法

调度规则(考量指标)

  • CPU使用率:CPU处于忙状态的时间百分比
  • 吞吐量:单位时间内完成进程的数量
  • 周转时间:一个进程从提交到完成之间的时间间隔,包括:
    • 实际执行时间
    • 等待资源的时间
  • 响应时间 :对于交互进程来说,指提交一个请求到开始接收响应之间的间隔。
  • 可预测性:响应实际和周转时间不能抖动太大。。

决策模式

调度算法执行时的处理方式,有两种

  • 非抢占:一旦进程处于运行状态,就会不断执行直到终止,在此之前只有两种可能会导致中断。
    • 等IO
    • 被自己请求的某些服务阻塞自己。
  • 抢占:正在运行的进程可能被OS中断,并转化为就绪态。 抢占式虽然开销大,但是能避免一个进程长时间独占处理器的情况。

调度算法

0. 优先级调度

为每个流程分配优先级,首先执行具有最高优先级的进程

1. 先来先服务FCFS

依据进入就绪状态的先后状态排队 优点:简单

缺点: 1) 不利于短作业。平均周转时间波动大,取决于任务顺序 短进程越靠前,平均周转时间越小 2)I/O资源、CPU资源利用效率低 OS P264 当正在进行的进程是CPU密集型,那么后面如果有I/O密集型进程在排队,他也无法在当前进行I/O,只能等着。

2. 短进程优先 SPN

解决FCFS不利于短作业的缺点,选择就绪队列中执行时间最短的进程占用CPU进入执行状态,按照预期执行时间来排序。

  • 预期时间的计算方式:指数平均法,观测值越旧,其算入平均值的比例越小。OS P266
  • SPN具有最优平均周转时间。

缺点:

  • 1) 可能会导致饥饿,连续的短进程流会使得长进程无法获得CPU资源。
  • 2) 需要预测未来进程的执行时间,麻烦

3. 短剩余时间优先 SRT

对SPN的可抢占改进。如果新来了一个进程,它的预期执行时间要比正在执行的进程的剩余执行时间要短,那么执行这个新来的进程。

4. 最高响应比优先 HRRN

  • 为了避免连续的短进程导致长进程被无限期推延执行,是一种既照顾短进程又照顾长进程的算法,是FCFS和SPN的折衷。
  • 响应比计算:R = (W + S) / S
    • W 为等待时间
    • S 为处理时间
    • 分析:使得W / S 增加的方法有两个
      • 增加W:等待时间更长,照顾长时间等待的长进程
      • 减少S:进程执行时间更短,照顾短进程
  • 类似SPN SRT,HRRN 也需要预估执行时间。

5.时间片轮转算法 RR

解决了FCFS不利于短作业的缺点,也解决了SPN SRT可能饿死长作业的缺点。

  • 时间片为CPU执行一个进程的最大时间,周期性地产生时钟中断。
  • 中断出现时,当前进程放入就绪队列,然后基于FCFS选择下一个就绪进程。
  • 时间片长度的设置
    • 过长,极端情况导致RR退化为FCFS
    • 过短,导致切换开销过大
    • 设置为10ms,切换开销占1%
  • 缺点:对I/O密集型进程和CPU密集型进程的处理不一样,可能导致I/O密集型进程性能降低。(I/O密集型占用CPU的时间更短:因为I/O密集进程执行后,进程会被阻塞,等待I/O操作完成)
    • 解决办法:虚拟轮转算法 VRR OS P265-267

6. 多级队列调度算法 MQ (综合性算法)

  • 就绪队列被划分为多个子队列,每个子队列内有自己的调度算法。
    • 比如:分为前台队列、后台队列。
  • 队列间也有调度算法:
    • 比如:固定优先级,先处理前台,再处理后台
    • 比如:时间片轮转,前台占80%,后台占20%

6. 多级反馈队列调度算法 MLFQ (综合性算法)

  • 一个子队列中的进程可以移动到另一个子队列中。

  • 比如:各个子队列采用优先级排序,子队列内部采用时间片轮转,优先级越低的子队列时间片越长。

    • 如果一个进程在当前子队列的一个时间片内没有执行完,那么它就会降级。
    • CPU密集型就会在更低的优先级上,时间片更长,切换开销更小。
    • I/O密集型线程会在更高优先级的队列上,时间片更短,解决了CPU密集型占用过多CPU时间而I/O密集型性能降低的问题。(I/O密集型占用CPU的时间更短:因为I/O密集进程执行后,进程会被阻塞,等待I/O操作完成)
  • 缺点:长进程可能饥饿,解决办法是给进程设定一个等待超时时间,大于这个时间就给他升级。

7.公平共享调度算法 FSS

内存管理

计算机体系结构

内存管理方式

逻辑地址和物理地址

  • 物理地址:硬件支持的地址空间,是绝对地址。
  • 逻辑地址:在CPU运行的进程看到的地址,由操作系统建立,方便程序访问变量。编译器编译程序时,会为程序生成代码段和数据段,程序中的每句代码和每条数据都会有自己的逻辑地址。

连续内存分配和内存碎片

  • 连续内存分配:一个进程占用的空间是连续的
  • 出现内部碎片的原因:一个进程内部语句执行完了。
  • 出现外部碎片的原因:一个进程执行完了,空间被释放,由于连续存储,新来的进程如果太大的话可能没法直接利用空出来的空间。所以非连续内存分配直接规定好页框大小。

动态分区分配

OS 需要维护数据结构:

  • 已分配的分区列表
  • 空闲分区列表 分配算法:OS P209
    • 首次适配
    • 最佳适配

非连续内存分配

非连续内存分配:一个进程占用的空间被分散在内存中 为何要非连续?

  • 因为可能找不到连续的内存空间,无法存放数据。
  • 会产生碎片 非连续内存分配的困难:
  • 虚拟地址到物理地址的转换
  • 不连续的内存每块的大小选择:页式、段式

段式内存管理

分段

段表

段式存储地址转换流程
  • PCB在物理内存的系统区中,当执行到该进程时,把PCB中的信息放到寄存器中,包括段表。
  • 在访问段表时要进行合法性检查。

页式内存管理

分页

页式内存管理中逻辑地址与物理地址间的转换

第2点需要页表来实现。

  • 32位总线地址,4KB分页计算机中二进制的转换方法:
    • 前20位表示逻辑地址中的页号
    • 后12位表示逻辑地址中的页内偏移量
页式存储管理的逻辑地址结构

页表

隐含:直接用逻辑内存中的页号 * 页框长度求出地址,页号不单独占用物理内存。

页式存储地址转换流程
  • PCB在物理内存的系统区中,当执行到该进程时,把PCB中的信息放到寄存器中,包括页表。
  • 程序计数器PC在PCB中,应该指向下一条指令的逻辑地址,也需要恢复。下面是计算物理地址的过程。
局部性原理

快表 vs 慢表

使用快表的页式存储地址转换流程

多级页表
单级页表存在的问题
  • 页表必须连续存放,当页表很大时,需要占用很多连续的页框,也可能填不满页框,造成碎片。
    • 页表占用内存为表示块号的字节数 * 页表项数量。
  • 由局部性原理可知,没有必要让整个页表都常驻内存,可以用虚拟存储技术。见后面
多级页表原理、逻辑地址结构
  • 一句话:对页表进行非连续存储
  • 详细:
    • 将页表进行分组,使得每个页框(分散的)的容量能刚好放下一个分组(分组内的页表是连续的)。
    • 使用页目录表来保存映射关系,<分组的序号, 该分组在内存中的页框号>
    • 页目录表是顺序存储的。

多级页表的地址变换

分段、分页的对比

不同点

5. 分页空间利用率高,不会产生外部碎片,只会产生少量内部碎片。分段管理会产生外部碎片

共同点
  • 分页机制和分段机制都是为了提高内存利用率,较少内存碎片。
  • 页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中的内存是连续的。
  • 单级分页和分段都可以引入快表。
  • 单级分页和分段在进行地址转换时都需要两次访存
    • 第一次:页表/段表
    • 第二次:内存

段页式内存管理

结合分页分段的优点,先分段,再把段分页,内存中放页面

  • 分段:更好的共享和保护
  • 分页:更高的空间利用率。少碎片。
段页式管理的逻辑地址结构

段页式管理的段表、页表

段页式中,一个进程只对应一个段表,但可能对应多个页表

段页式存储地址转换流程

  • 这里的段表和段式中的段表结构不一样。
  • 单段页式在进行地址转换时需要三次访存,可以引入快表减少查询次数。
    • 第一次:段表
    • 第二次:页表
    • 第三次:内存
  • 在访问段表时要进行合法性检查。

虚拟内存

  • 传统存储管理方式的特征和缺点

  • 存储速度:外存》内存》高速缓存》寄存器

虚拟内存定义

请求分页存储管理

请求分页存储管理的逻辑地址结构

缺页中断

请求分页存储管理的地址变换

  • 与基本分页存储管理的区别:新增三个步骤
  • 在具有快表机构的请求分页系统中,访问一个逻辑地址时,如果发生缺页,则地址变换步骤是:
    • 查快表(未命中) -> 查慢表(不在内存中) -> 调页操作(修改慢表项中的状态位,把表项直接加到快表中) -> 查快表(命中)

页面置换算法

调页的I/O操作开销很大,因此页面置换算法要保证尽可能低的缺页率。

最佳置换 OPT
  • 不可实现
先进先出 FIFO
  • 实现简单,但性能差
    • 最先进入队列的页面不等于被访问频率最低的
    • 可能发生Belady异常
Belady异常

最近最久未使用 LRU
  • 性能好,但开销大
  • LRU和FIFO本质都是先进先出的思路,
    • 但LRU是针对页面的最近访问时间来进行排序,所以需要在每一次页面访问的时候要动态的对各个页面排序(按照当前距最近访问时刻的时长)
    • FIFO针对页面进入内存的时间来进行排序,这个时间是固定不变的,所以页面之间的先后顺序是固定不变的。

时钟置换算法 CLOCK

也叫最近未用算法NRU(Not Recently Used)

  • 在性能和开销之间折衷的算法
    • 在每一次页面访问时,它不必去动态调整页面在链表中的顺序,而仅仅是做一个标记1
    • 在淘汰时,对于内存中未被访问过的页面,直接淘汰,性能和LRU一样好;对于曾经访问过的页面,无法准确记住时间顺序,只能淘汰访问过的页面中的其中一个,所以才叫NRU。

  • 例子:时钟算法不会记住时间顺序

死锁

image.png

image.png

产生死锁的条件

举例:哲学家问题

  • 1 互斥。一次只能有一个进程使用一个资源,其他进程不能访问。只有对必须互斥使用的资源的争抢才会导致死锁 (一个人拿一根筷子)
  • 2 占有且等待。当一个进程等待被其他进程占有的资源时被阻塞,但是不释放已占有的资源。(一个人等别人放下筷子时,同时自己一直拿着筷子)
  • 3 不可抢占。某进程的资源在使用完之前,其他进程不能强行抢夺。(每个人拿到筷子后,别人不能抢)
  • 4 循环等待。存在一种闭合的等待链,每个进程占有下一个进程所需要的一个资源。

第四个条件是前三个条件的潜在结果。如果前三个条件存在,可能发生一系列事件导致第四个条件。实际上第四个条件就是死锁的定义。

前三个条件只是必要条件。第四个条件也是必要条件:循环等待不一定会导致死锁,比如哲学家问题中,几个哲学家循环等待时,可能有另外一个不在循环圈中的哲学家可以提供筷子(不占有且等待),即同类资源数大于1。仅当同类资源数等于1,那么循环等待是死锁的充要条件。

2E5C8FEBAF06775707E057AF81925EFD.png 因此,只有四个条件加在一起才是死锁的充要条件。

预防死锁

  • 破坏互斥条件,把需要互斥访问的资源改造成逻辑上共享的。 比如使用SPOOLing技术,把多个进程交给打印机的输出进程来处理。 image.png
  • 破坏不可抢占夺条件
    • 方案一:在任意两个进程优先级都不同的情况下,当一个进程请求被另一个进程占有的资源时,操作系统可以让另一个进程释放资源,强行剥夺。
    • 方案二: 当一个占有某些资源的进程进一步申请更多的资源而被拒绝时,该进程立刻释放其最初占有的资源,等待以后需要时再次申请。即:某些资源尚未使用完也要主动释放。释放已获得的资源可能导致前一阶段工作失效,因此此方案只适用于易于保存和恢复状态的资源,如CPU。
  • 破坏占有且等待条件 在一个进程运行前,一次性的申请完它所需要的全部资源,在它的请求未被全部满足前,该进程被阻塞;一旦投入运行,这些资源就归该进程占有,同时该进程也不会请求其他的资源。

缺点:资源浪费。

  • 破坏循环等待条件 给每个资源编号,每个进程必须按照编号递增的顺序请求资源。拥有大编号资源的进程不能逆向回来申请小编号资源。

避免死锁

使用银行家算法+安全性算法来判断当前某个进程对资源的请求是否会导致死锁,如果会导致死锁,则不满足这一请求。

安全状态:即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得不存在死锁(每一个进程运行完毕),则称该状态是安全的。

不安全状态:如果有进程无法运行完毕,它的资源也不会被释放,即表明出现了死锁,则不为这次请求分配资源。

  • 银行家算法

image.png

image.png

  • 安全性算法(预演判断是不是安全的,并不是真正的分配) :
    • 假设已经为此次p0的申请request0分配完资源,把p0加到安全序列中,此后,检查当前剩余的资源available[] 是否能满足p0以后的某个进程的最大需求(即遍历最后一列“最多还需要”中的各个need矩阵,index为1~4, available的各个位都需要大于等于need中对应的位),
      • 如果找到了这样的一个进程pi,就把pi加入安全序列,并把已分配给pi的所有资源回收,加到available[]中(因为pi执行完后会释放这些资源,现在我知道pi肯定会被执行)。继续从头遍历最后一列中的各个need矩阵(已经加到安全序列中的行不遍历,直接跳过)。
    • 如果有进程没有被加入到安全序列中,说明存在死锁进程,这次分配是不安全的,不应该执行,应该避免。
    • 如果p0外的所有进程都加入安全序列了,说明这次分配是安全的,可以进行。

死锁检测

用于确定当前是否存在死锁

  • 算法:类似上面的安全性算法,
    • 一开始选取一个allocation为全为0的p加入到安全序列中(因为它没占用资源,不存在死锁风险),
    • 然后遍历剩下p们并释放资源到avaliable[]数据结构中。
    • 如果有进程没有被加入到安全序列中,说明存在死锁,如果p都被加入安全序列,说明当前不存在死锁。