操作系统基础篇(二)

进程的分身术——线程

1.概述
前面的博客讲到了进程的缺点,从而发明了线程。
举个例子:每个人再人生的某个时候,都希望自己能够分身,从而完成多件事情。而一个进程相当于一个人,也希望能够分身,完成更多使命。于是有了线程。
进程在一个时间只能做一件事,线程就可以同时做多件事,进程既然是线程的分身,每个线程的本质实际上也是一样的,即拥有同样的程序文本。
线程也是进程里的一个执行上下文,或者称为执行序列。同一个地址空间里面的所有线程构成了进程。
在线程模式下,一个进程至少有一个线程,也可以有多个!
在这里插入图片描述
将进程分解为线程还可以有效利用多处理器和多核计算机,在没有线程的情况下,增加一个处理器并不能让进程的执行速度提高。但如果分解为多个线程,可以让不同的线程同时运转在不同的处理器上,从而提高了进程的执行速度。
比如打开excel,可能是开启了多个线程:
在这里插入图片描述
2.线程管理
想要正常跑线程,就要维护线程的各种信息,包含了各种关键资料,而存储这些线程的数据结构叫做线程控制表或者线程控制块。那么这个线程块都有什么东西呢?
要知道,线程共享一个进程空间,正是由于共享,共享的资源显然不存在线程控制块内,而是存放在进程控制块内。但是总有不被共享的资源,这些资源显然就存在进程控制块内!
为了达到我们的目的,虽然有些资源能共享,那么我们就将这些资源共享的概率让他尽可能的大!这也是我们创建线程的初衷。因此我们定向下了一个评判标准:
如果某资源不独享会导致线程运行错误,那该资源就由某个线程独享!而其他资源由进程里面的所有线程共享!
举个例子:按照这样的标准来划分,共享的资源有:地址空间,全局变量,文件,子进程等等。定时器,信号和占用CPU时间也可以共享。但是程序计数器不能共享,因为每个线程执行序列不一样。同理,寄存器,栈也不能共享,这是线程的上下文(运行环境)。如图:
在这里插入图片描述
3.实现方式
1)进程自己管理线程
2)操作系统来管理线程
这两种决定了是内核态线程还是用户态线程。
那么为什么在进程上,没有用户态和内核态呢?这是因为进程在CPU上实现并发(多道编程),而CPU是由操作系统管理的,因此,进程的实现只能由操作系统内核来进行,而不存在用户态的情况。
4内核态线程实现
线程是进程的分身,是进程的不同执行序列,也是CPU调度的基本单位。但是CPU调度又是操作系统实现的,那么怎么管理呢?与管进程一样,要维护线程的各种资料,即将线程控制块存放在操作系统内核空间。这样,线程与进程控制块信息都在内核,如图:
在这里插入图片描述
由操作系统来管理线程好处很多,最重要的就是用户编程保持简单,且此时操作系统能够监控所有线程。
缺点:
效率低下,因为线程在内核态,每次线程切换都要陷入到内核,由操作系统调度,而从用户态陷入到内核是时间开销比较大的。进程数量的增多,内核空间也将迅速减少!
最要命的:
内核态的实现需要修改操作系统,如果那一天,你是科学家,发明了内核态线程,要去找一家操作系统研发商,要求其修改操作系统,别人会叫你走开,乘凉去。因为这样,就有了用户态线程
5.用户态线程
用户自己做线程切换,自己管理线程信息,而操作系统无需直到线程的存在。
用户自己写一个执行系统做调度器,即除了正常执行的线程外,还有一个专门负责线程调度的线程,没有能力强行夺走CPU控制权,所以必须合作。因为在用户态执行,谁也不比谁占优势,想要获得控制权,自能靠大家的自愿合作。一个线程在执行完一段时间后主动把资源释放给别人,而在内核态下无须如此,因为操作系统可以通过周期性的时钟中断把控制权夺过来。
在这里插入图片描述
优点
用户态实现很灵活,在所有操作系统上都能应用,而且线程之间的切换速度快,不用去修改操作系统
缺点
编程的不同,效率差别很大,更严重的是不能完全达到线程提出所要达到的目的:进程级多道编程
如果在执行过程中线程受阻,将无法将控制权交出来,因为都不能执行交出CPU的指令了,那么这个线程将影响整个进程,这是线程的致命弱点!
如何让解决
1)不让线程调用阻塞操作
我们写一个包裹(wrap)把系统调用包裹起来,他来检测发出的系统调用会不会阻塞,如果会,就禁止调用。
例如:在读磁盘时,如果可以迅速获得,就允许执行,反之,到了不阻塞再来调用,这样可以一定程度上解决阻塞问题,但是缺点是:
(1)需要修改操作系统,将系统调用wrap包裹起来。
(2)降低了系统线程的效率。
(3)阻塞的线程过后不一定会变成不阻塞,所以不一定成立
2)在进程阻塞后相伴大激活受阻塞的其他线程(调度器激活)
这种方法必须依赖操作系统,因为线程阻塞后,CPU的控制权已经回到了操作系统的手里。唯一的办法就是让操作系统在进行进程切换时不切换,而是通知受阻的进程调用执行系统,并问其是否还有别的线程可以执行。这种做法称为第二次机会。因为在一个进程挂起后,操作系统饼不立即切换到别的进程,而是给该进程第二次机会,让其继续执行。但是如果该进程只有一个线程,或者其他线程都已经阻塞,则控制权再次会回到操作系统手里。而这次,操作系统就会切换到别的进程了。
缺陷:
对操作系统的安全隐患增加了,因为第一次阻塞不会退出该进程,黑客可以通过此漏洞进行各种攻击,且这属于下层调用上层,违反了层次架构原则!
6.现代操作系统的线程实现模型
将二者结合是如今操作系统使用的。
用户态的执行负责进程内部线程在非阻塞时的切换,内核态负责在阻塞时的线程切换,将会阻塞的线程分为内核态一组,非组赛的分为用户态一组,这样就获得了两者的优点:
在这里插入图片描述
7.多线程的关系
推出线程模型的目的就是为了实现进程级并发。因为一个进程中通常会出现多个线程,但是由于共享的一些信息会产生dispute,可以总结为下面两个问题:
1)线程之间如何同步?
2)线程之间如何通信?
上述两个问题在进程层面也同样存在。从更高的层次上看,不同进程也共享着一个巨大的空间,这个空间就是整个计算机!
8.从用户态进入内核态
什么时候会从线程用户态进入内核态呢?
在程序运行过程中出现中断或者异常,系统将自动切换到内核态来运行中断或者一场处理机制。如图为中断导致态势切换的流程,异常处理的流程与此相同或相似。
在这里插入图片描述
此外,程序进行系统调用也将造成从用户态到内核态的转换。
举例:
一个C++程序调用函数cin,cin是一个标准库函数,他将调用read函数,而read函数是由操作系统提供的一个系统调用,其过程如下:
1)执行汇编语言里面的系统调用指令(syscall)
2)将调用的参数sys_read,file number,size存放在指定的寄存器或者栈(事先约定好)
3)当处理器执行到syscall指令时,察觉这是一个系统调用指令,将进行如下操作:
a)设置处理器为内核态,
b)保存当前值(栈指针,程序计数器,通用寄存器)
c)将栈指针指向内核栈指针。
d)将程序计数器设置为事先约定好的地址上,该地址上存放的是系统调用处理程序的起始地址
4)系统调用处理程序执行系统调用,并调用内核里面的read函数
这样就完成了从用户态到内核态的转换,并完成系统调用所要求的功能。
9线程的确定性与非确定性
线程实现了进程级并发,提高了系统效率或者吞吐量,同时改善了用户感觉到的响应时间。
问题
由于多线程的存在,就每个单一线程看,其执行效率,正确率,正确率均存在不确定性。当然我们可以通过同步机制,可以改善这种不确定性,但是在多线程执行过程中出现呢异常,则情况就相当麻烦!
线程的机制非常像类似于硬件的流水线机制。流水线也是并发,不过是指令级的(非程序级)。如图为流水线的指令级并发和线程的程序级并发的对比:
在这里插入图片描述
从某种程度上来说,线程和流水线分别是软件层和硬件层不确定性的根源。流水线使得我们可以在硬件指令执行上并发,线程则使我们在软件指令上并发。

线程通信

线程对白:管道,记名管道,套接字
线程对白就是一个线程发出来的某种数据信息,另外一方接收数据信息,这些数据通过一片共享的存储空间进行传递。

管道

一个线程向存储空间一端写入信息,另一个线程从存储空间的另一端读取信息,这就是管道,管道既可以是内存就也可以是磁盘,要创建一个管道,一个线程只需要调用管道创建的系统调用即可。
管道是一个线性字节数组,类似文件,使用文件读写的方式进行访问,但却又不是文件,因为通过文件系统看不到管道的存在,创建管道在壳命令行下和在程序是不同的,在壳命令下,只需要使用符号"|"即可。
UNIX下创建管道

$ sort < file1 | grep zou

在两个utility"sort"和"grep"之间创建了一个管道
在程序下创建管道
创建管道需要使用系统调用popen()或者pipe()。popen需要提供一个目标进程作为参数,然后在调用该函数的进程和给出的目标进程之间创建一个管道。
创建时还需要提供一个参数表明管道类型:读管道或者写管道,而pipe调用将返回两个文件描述符,其中一个用于从管道进行读写,一用于写管道。也就是说,pipe将两个文件描述符连接起来,使得一端读,另一端可以写。通常情况下,在使用pipe调用创建管道后,再使用fork产生两个进程,这两个进程使用pipe返回的两个文件描述符进行通信。
例子:

int pp[2];
pipe(pp);//创建管道
if(fork()==0)//子进程
{
    
    
read(pp[0]);//从父进程读
............
}
else
{
    
    
write(pp[1]);//写给子进程
.............;
}

管道的另外一个重要特点是使用管道的两个线程之间必须存在某种关系。例如,使用popen需要提供另一端进程的文件名,使用pipe的两个线程则分别隶属于父子进程。

记名管道

如果要在两个不相关的线程间进行通信,比如不同进程的线程,则需要使用记名管道。记名管道与文件系统共享一个名字空间,及我们可以从文件系统中看到记名管道,也就是说,记名管道的名字不能与文件系统里的任何文件重名。
例如:
在UNIX下使用ls命令可以查看到已经创立的记名管道。

% ls-l fifol
prw-r--r-- 1 jhon user 0  Sep 22 23:11 fifol |

一个线程通过创建一个记名管道后,另外一个线程可以使用open来打开这个管道(无名管道不能进行open操作),从而与另一端进行交流。
记名管道名称组成:由计算机名和管道名组成,例如:\[主机名]\管道[管道名]\。

优缺点
管道与记名管道无需特殊设计(指应用程序方面)就可以与另外一个进程进行通讯,但是不是所有操作系统都支持此功能,现在支持管道通信方式的是UNIX和类UNIX的操作系统。不适用于其他操作系统,其次,管道通信需要在相关的进程间进行(无名管道),或者需要直到名字来打开(记名管道),会显得十分不便。

虫洞:套接字

套接字是另一种可以用于进程间通信的机制。他几乎渗透到所有主流操作系统,可支持不同层面,不同应用,跨网络的通信。
大致流程:
通信双方都需要创建一个套接字,其中一方作为服务器方,另一方作为客户方,服务器方必须先创建一个服务区套接字,然后在该套接字上监听,等待远方的连接请求。想要与服务器方通信的客户自创建一个客户套接字,然后向服务区套接字发送连接请求。服务器套接字在收到连接请求后,将服务器上创建一个客户套接字,与远方的客户机上的客户套接字形成点对点的通信信道。之后客户端和服务器端就可以通过send和recv命令在这个创建的套接字上进行交流了。
绑定
服务器想要提供网页浏览服务需要绑定,服务器套接字可以绑定道某个公共主机的一个众所周知的端口,当然,如果绑定道某个具体的IP地址,则只能在本地机器上使用!
注意
服务器套接字不发送数据,也不接收数据(用户数据,而非连接请求数据),而是仅仅生成出“客户”套接字,生成后,原本的服务套接字则回到原来的监听操作上。

线程电报:信号

管道和套接字的效率十分低下,且耗资源。而信号就是我们要讲的下一个通信机制。
**是什么:**在计算机里,是一个内核对象,或者说是一个内核数据结构。发送方将数据结构的内容填好,并指明该信号的目标进程后,发出特定的软件中断通知操作系统,操作系统知道是有进程要发送信号,于是到特定的内核数据结构里查找信号接收方,并进行通知,接收到通知的进程对信号进行相应处理。

线程旗语:信号量

在计算机里,信号量就是一个简单的整数,一个进程在信号变为0或者1的情况下推进,并且将信号变为1或者0来防止别的进程推进。
信号量不光是一种通信机制,更是一种同步机制。

扫描二维码关注公众号,回复: 13030493 查看本文章

线程拥抱:内存共享

一个进程首先创建一片内存空间专门作为通信用,而其他进程则将该内存空间映射到自己的虚拟地址空间,这样读写自己地址空间中对应共享内存的区域时,就是在和其他进程通信(随机)。
优缺点
灵活度高,传递信息也复杂得多,但是两个进程必须在同一台物理机器上才能使用,且安全性较低。
在这里插入图片描述
注意:使用全局变量在同一个进程的线程间实现通信不称为共享内存你。

信件发送:消息队列(只在内存中应用)

一列具有头和尾的消息排列。新的消息放在尾部,而读取消息则从队列头部开始:
在这里插入图片描述
它类似管道,但是它无需固定的读写线程,任何进程都可以读写,也可以同时支持多进程,多个进程可以读写消息队列。即多对多,不是管道的点对点。流行于几乎所有主流操作系统。

其他通信机制

通信机制繁多,但是原理却并无太大差异。归根结底都来源于AT&T的UNIX V系统。

进程同步

某个共享的全局变量,在一个线程执行了一些操作后想检查当前变量的值,但是在即将做出检查之前,另一个线程却在此时修改了此变量,造成了数据的错误!
为什么要同步:
1)线程之间的共享的全局共享变量
2)线程之间的相对执行顺序是不确定的
一般情况下,在任何计算机体系结构中,一行代码不是一条微指令,即一条高级指令对应的是多条微指令。
测试:
在这里插入图片描述
请问哪个线程会赢??答案是不确定的!!
上述可知,引入线程后,同时也引入了巨大的问题,就是多线程执行的结果是不确定的,这样,线程就必须同步!!

线程同步的目的:
同步就是让所有的线程按照一定的规则执行,使得其正确性和效率都得到控制。也就是对线程之间的穿插进行控制。
为什么程序会出现错误呢?因为产生了竞争。
竞争:两个或者多个线程争相执行同一段代码或者访问同一资源的现象。这个可能造成竞争的共享代码段或资源就称为临界区。
当然,在单核情况下,不会出现同时执行同一段代码,但是却有可能在同一时刻两个线程都在同一段代码上,这是代码竞争。如果两个线程同时访问一个数据就是数据竞争。
互斥:只有一个线程能够在临界区,其他线程排除在外。
达到互斥的条件:
1)不能有两个线程同时在临界区
2)能够在任何数量,和速度的cpu上正确运行。
3)互斥区域外不能阻止另外一个进程的运行
4)进程不能无限制的等待进入临界区。

锁的基本操作:
1)等待锁达到打开状态
2) 获得锁并锁上锁
读者可能会问了,表面上可行,但是如果在此时出现交叉执行怎么办?
那就需要的是原子操作,就是指令执行不能分开,一次性执行完才行。
原子操作需要硬件的支持,软件是无法达到原子操作的。
如果一个线程获得锁后,另一个线程只能等待,如果获得锁的事件很长怎么办?一直等下去吗?我们只能减短等待的时间,还不能消除
解决办法:
一个线程获得锁后,在释放锁之前,找一个标志位,置为,接着释放锁,再执行原子操作,如果执行完毕清零,在另一个线程获取锁后,检查表标志位的状态值,如果清零,则进行原子操作,否则执行原子操作外的功能。
但是无论怎样,都有等待时间,如何消除呢?
那就是睡觉与叫醒
睡觉与叫醒:
如果锁被对方持有,你不用等待锁变为打开状态,而是睡觉,锁打开后再把你叫醒
在这里插入图片描述
睡觉与叫醒是原语操作,一个程序调用sleep后,进入休眠状态,其锁占用的cpu被释放,一个执行wakeup的程序将发送一个信号给接收线程,如wakeup(producer)就是送一个信号给生产者。
在这里插入图片描述
那么问题又来了!
count变量没有进行保护没可能存在数据竞争,所以好的办法就是在count变量前后加入lock与unlock。
上面陈述的问题解决了,还有个问题,就是在生产者和消费者不会从睡觉中醒来,所以如果二者都去睡觉了,自然无法叫醒,造成这样的原因是因为生产者发出的叫醒信号丢失(此时消费者还没睡觉),那我们就想,将发出的信号累积起来,而不是丢掉!
比如,在消费者执行sleep后,生产者在这之前发送的叫醒信号还保留,因此,消费者将马上获得这给信号醒过来,而能将信号累积起来的操作系统原语就是信号量!

信号量

信号量是原语里面功能最强大的,不仅是同步原语,还是通信原语,还能作为锁来使用。
信号量说白了就是一个计数器,其取值为当前累积的信号数量,有两种操作。
Down减法操作:
1)判断信号量的取值是否大于等于1
2)如果是,将信号量减1,继续往下执行
3)否则在该信号量上等待(该线程被挂起)
Up加法操作:
1)将信号量的值增加1(此操作将叫醒一个在该信号量上面等待的线程)
2)线程往下继续执行
注意:虽然这两个操作分为几步操作,但是他们都是原子操作。
如果我们将信号量的取值限制为0和1两种情况,我们将获得的是一把锁,也成为二元信号量:
二元信号量Dowm减法操作:
1)等待信号取值变为1
2)将信号量的值设置为0
3)继续往下执行
二元信号量Up操作:
1)将信号量的值置1
2)叫醒在该信号量上面等待的第一个线程
3)继续往下执行
应用:
使用二元信号量来进行互斥:

down()
<临界区>
up()

可以说,二元信号量就是锁和睡觉与叫醒两种原语操作的合成
缺点:我们现在只有两个信号量比较简单,但是一旦出现十几个或者几十个,我们就很难做到不发生死锁或者效率低下,于是我们用到了管程

管程(监视器)

有编译器管理,正确性依赖于编译器

你不行的时候,把困难交给被人

管程就是一组子程序,变量和数据结构的组合,换句话说就是你需要把同步的代码用一个管程的构造框起来,将需要保护的代码置于begin monitor与end monitor之间,即获得同步保护。

猜你喜欢

转载自blog.csdn.net/weixin_42271802/article/details/106137941