您的位置:首页 - 教程 - IOS - 正文
操作系统——进程与线程笔记

本来不打算在现在这个阶段来看操作系统书籍的,但是入手一本《iOS逆向工程》,看它需要MAC OS的相关知识,便入手了一本《深入解析 MAC OS X & IOS 操作系统》,发现看它需要操作系统的相关知识,所以有了这些笔记,果然,no zuo no die。

 

1、进程

一个进程就是一个正在运行的程序,它有自己的地址空间,在进程内的所有线程共享这个进程的地址空间,也就是说,共享这个进程的资源,包括程序计数器、寄存器和变量的当前值。

在单核计算机年代,任何多道程序设计系统中,CPU由一个进程快速切换到另一个进程,使得每个进程在1s内各自运行几十毫秒到几百毫秒。严格的说,在某一个瞬间,CPU只能运行一个进程,但在1s期间,它可能运行多个进程,这样就产生了并行的错觉,在这个章节,默认讲解的是单核计算机。

由于CPU在各个进程之间来回快速切换,所以每个进程其执行速度都是不确定的,而且当同一进程再次运行时,其运算速度通常也不可再现。每一个进程都是一个独立的实体,有其自己的程序计数器和内部状态,但是进程之间是可以相互作用的,一个进程的输出结果可以作为另一个进程的输入。

当一个进程在逻辑上不能继续运行时,它就会被堵塞,典型的例子是它在等待可以使用的输入。还会存在这样的情况:一个概念上能够运行的进程被迫停止,因为操作系统调用另一个进程占用了CPU。(例如,当一个进程从设备文件读取数据时,如果没有有效的输入存在,则进程会被自动阻塞)

 

进程表:为了实现进程模型,操作系统维护着一张表格(数组),即进程表,每个进程占用一个进程表项。该表保存了进程状态的重要信息,包括程序计数器、堆栈指针、内存分配状况、所打开文件的状态、账号和调度信息,以及其他在进程由运行态转换到就绪态或者堵塞态时必须保存的信息,从而保证该进程随后能再次启动,就像从未被中断过一样。

运行态:该时刻进程实际占用CPU

就绪态:可运行,但因为其他进程正在运行而暂时停止

阻塞态:除非某种外部事件发生,否则进程不能运行

 

多道程序设计模型:采用多道程序设计可以提高CPU的利用率。严格的说,如果进程用于计算的平均时间是进程在内存中停留时间的百分之二十,且内存中同时有五个进程,则CPU一直将满负载运行,然而这个模型在现实中过于乐观,因为它假设这五个进程不会同时等待I/O。

更好的模型是从概率的角度来看待CPU的利用率的,假设一个进程等待I/O操作的时间与其停留在内存中的时间比为p,当内存中有n个进程时,则所有n个进程都在等待I/O(CPU空转)的概率是p的n次方,所有可以得到CPU的利用率:

CPU的利用率 = 1 - p^n;

 

 

2、线程

为什么需要多线程?主要原因是在一个进程中会同时发生着多种活动, 其中某些活动随着时间的推移会被堵塞,通过将这些活动分解成可以准并行运行的多个顺序线程,程序设计模型会变得更简单。由于线程比进程更轻量级,所有它比进程更容易创建和撤销。

进程之间各自空间地址不同,但是同一个进程里面的线程,它们共享着同一个空间地址和所有可用数据的能力。

还有一个原因就是性能方面的问题,若多个线程是CPU密集型的,那么并不能获得性能上的加强,但是如果存在大量的计算和大量的I/O处理,拥有多个线程允许这些活动彼此重叠进行,从而会加快应用程序执行的速度。

线程中有一个程序计数器,用来记录接着要执行哪一条指令;有寄存器,用来保存线程当前的工作变量;还有一个堆栈,用来记录执行历史,其中每一帧保存了一个已调用但还没有从中返回的过程。

尽管线程必须在某个进程中执行,但是线程和它的进程是不同的概念,并且可以分别处理。进程用于把资源集中到一起,而线程则是在CPU上被调度执行的实体。

线程与进程一样,也可以处于若干状态中的任何一个:运行、堵塞、就绪、终止。正在运行的线程拥有CPU并且是活跃的,被堵塞的线程正在等待某个释放它的事件,就绪线程可被调度运行,并且只要轮到它就可以很快运行,线程状态之间的转化与进程是一致的。

认识到每个线程有自己的堆栈很重要,每个线程堆栈都有一帧,供各个被调用但是还没从中返回的过程使用。在该帧中存放了相应过程的局部变量以及过程调用完成之后使用的返回地址。例如,如果过程x调用过程y,而y又调用z,那么当z执行时,供x、y、z使用的帧都会存在堆栈中。通常每个线程会调用不同的过程,从而有一个各自不同的执行历史,这就是为什么每个线程都有自己的堆栈的原因。

 

3、进程间通信

进程通常需要与其他进程通信,但可能会产生一些问题。

 

a、竞争条件

在操作系统中,协作的进程可能共享一些彼此都能读写的公共存储区,这个公共存储区可能在内存中,也可能是一个共享文件。当两个或者多个进程读写某些共享资源,而最终结果取决于进程运行的精确时序,成为竞争条件。

b、临界区

 怎样避免竞争条件?实际上凡是涉及多个进程读写某一共享资源的情况,关键是要找出某种途径来组织多个线程同时读写共享的资源。换而言之,我们需要的是互斥,即以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作。

我们把对共享内存进行访问的程序片段称之为临界区。如果我们能够适当的安排,使得两个进程不可能同时处于临界区中,就能够避免竞争条件。

对于一个好的解决方案,要满足下面的条件:

任何两个进程不能同时处于其临界区

不应对CPU的速度和数量做任何假设

临界区外运行的进程不能阻塞其他进程

不得使进程无限期等待进入临界区

c、忙等待的互斥

 屏蔽中断:

在单处理器中,最简单的办法是使每个进程在刚刚进入临界区后立即屏蔽所有中断,并在就要离开之前再打开中断。屏蔽中断后,时钟中断也被屏蔽,CPU只有发生时钟中断或其他中断时才会进行进程切换,这样,在屏蔽中断后CPU将不会被切换到其他进程。于是,当某个进程屏蔽中断之后,它就可以检查和修改共享内存,而不必担心其他进程介入。

这个方案并不好,因为把屏蔽中断的权利交给用户进程是不明智的。如果一个进程屏蔽中断后不再打开,整个系统可能会因此而终止。如果是多处理器系统(即多核CPU),则屏蔽中断仅仅对执行disable指令的那个CPU有效,其他CPU将继续运行,并可以访问共享内存。

屏蔽中断对于操作系统本身而言是一项很有用的技术,但是对于用户进程则不是一种合适的通用互斥机制。

 

锁变量:

设想有一个共享变量,其初始值为0,当一个进程想进入临界区时,它首先测试这把锁,如果该锁值为0,则进程进入临界区,并把锁设置为1。如果该锁为1,那么进程将等待直至其值变为0,于是,0表示临界区内没有进程,1表示已经有某个进程进入临界区。

但是,这种想法是有疏漏的,假设一个进程读出锁变量的值为0,而恰好在它将其值设置为1之前,另一个进程被调度执行,将该锁变量设置为1,当第一个进程再次能运行时,它同样也将该锁设置为1,则此时,同事有两个进程进入临界区。

 

严格轮换法:

 整型变量turn,初始值为0,用于记录轮到哪个进程进入临界区,并坚持或者更新共享内存。开始时,进程0坚持true,发现其值为0,于是进入临界区。进程1也发现其值为0,所以在一个等待循环中不停的测试ture,看其值何时变为1。连续测试一个变量,直到某个值出现为止,称为忙等待。由于这种方式浪费CPU时间,所以通常应该避免。

 

进程0离开临界区时,将turn值设置为1,以便进程1进入临界区,假设进程1很快就离开了临界区,则此时两个进程都在临界区之外,turn值又被设置为0。现在进程0很快就执行完其整个循环,它退出临界区,并将turn的值设置为1,两个进程都在其临界区之外执行。

突然,进程0结束了非临界区的操作并返回到循环的开始。但是,这时它不能进入临界区,因为turn的当前值为1,而此时进程1还在忙于非临界区的操作,进程0只有继续while循环,知道进程1将turn的值改为0。这说明,在一个进程比另一个进程慢了很多的情况下,轮流进入临界区并不是一个好办法。 

 

Peterson解法:

将锁变量与警告变量的思想结合,提出了一个不需要严格轮换的软件互斥算法:

 在进入临界区之前(使用共享变量之前),各个进程使用0或1作为参数来调用enter_region。该调用在需要时,将使进程等待,直到能安全的进入临界区,在完成对共享变量的操作之后,进程调用leave_region表示操作完成。若其他进程希望进入临界区,则现在就可以进入。

一开始没有任何进程处于临界区之中,现在进程0调用enter_region,它通过设置其数组元素和将turn设置为0来标识它希望进入临界区。由于进程1并不想进入临界区,所以enter_region很快就会返回。如果线程1现在调用enter_region,进程1将在此处挂起,知道interseted[0]变成FALSE,该事件只有在进程0调用leave_region才会发生。

现在考虑两个进程几乎同时调用enter_region的情况,它们都将自己的进程号存入turn,但只有后被保存进去的进程号才有效,前一个因被重写而丢失。假设进程1是后写入的,则turn为1,当两个进程都运行到while语句时,进程0将循环0次进入临界区,而进程1将不停的循环并且不能进入临界区,直到进程0退出临界区为止。

 

TSL指令:

某些计算机中,特别是多处理器计算机中,都有下面的这条指令:

TSL RX,LOCK

称为测试并加锁,它将一个内存字lock读入寄存器RX中,然后在该内存地址存入一个非零值。读字与写字保证是不可分割的,即该指令结束之前其他处理器均不容许访问该内存字。执行TSL指令的CPU将锁住内存总线,以禁止其他CPU在本指令结束前访问内存。

锁住存储总线不等于屏蔽中断,就屏蔽中断来说,它在读内存字之后跟着写操作,并不能阻止总线上的第二个处理器在读操作与写操作之间访问该内存字。也就是说,在处理器1上屏蔽中断,对于处理器2来说,是没有影响的。让处理器2远离内存直到处理器1完成,唯一方法就是锁住总线。

 

d、睡眠与唤醒

Peterson解法和TSL都是正确的,但它们都有忙等待的缺点,这些解法的本质是这样的:当一个进程想进入临界区时,先检查是否允许进入,如果不允许,则该线程原地等待,知道允许为止。

这种方法不仅浪费CPU,而且还可能引起预想不到的结果。考虑到一台计算机的两个进程,H优先级较高,L优先级较低。调度规则规定,只要H处于就绪状态,就可以运行,在某一时刻,L处于临界区,此时H变为就绪态,准备运行。现在H开始忙等待,但由于当H就绪时L不会被调度,也就无法离开临界区,所以H将永远等待下去,这种情况有时候被称为优先级反转问题。(在iOS开发中,苹果文档建议我们少使用dispatch_priority的高优先级别,尽量使用默认优先级别,也是有这种考虑在内的吧,但是那是线程,而不是进程)。

有几条进程间通信的原语,它们无法进入临界区时将阻塞,而不是忙等待。还有sleep和wakeup,sleep是一个将引起调用进程阻塞的系统调用,即被挂起,直到另一个进程将其唤醒。wakeup有一个参数,即要被唤醒的进程。

生产者-消费者问题,也称作界缓冲区问题。两个进程共享一个公共的固定大小的缓冲区,其中一个是生产者,将信息放入缓冲区,另一个是消费者,从缓冲区取出信息。

问题在于,当缓冲区已经满了,而此时生产者还想向其中放入一个新的数据项的情况,其解决方法是,让生产者睡眠,待消费者从缓冲区取出一个或者多个数据项时,再唤醒它。同样的,当消费者试图从缓冲区取出数据而发现缓冲区为空时,消费者就睡眠,知道生产者放入了一些数据再将消费者唤醒。

 现在回到竞争条件的问题,这里可能会出现竞争条件,其原因是对count的访问没有加以限制。有可能会出现这种情况:缓冲区为空,消费者刚刚读取到count的值发现它为0,此时调度程序决定暂停消费者并启动运行生产者。生产者向缓冲区中加入一个数据项,count++,现在count的值变为1了,它推断认为由于count刚才为0,所以消费者此时一定在睡眠,于是生产者调用wakeup来唤醒消费者。

但是,消费者此时在逻辑上并未睡眠,所以wakeup信号丢失。当消费者下次运行时,它将测试先前读到的count值,发现它为0,于是睡眠。生产者迟早会填满整个缓冲区,然后进入睡眠,这样一来,两个进程都将永远睡眠下去。

 

e、信号量

它使用一个整型变量来累计唤醒的次数,供以后使用。一个信号量的取值可以为0(表示没有保存下来的唤醒操作)或者为正值(表示有一个或多个唤醒操作)。

信号量有down和up操作,对一信号量进行down操作,先检查其数值,如果该值大于0,则减1(表示用掉一个信号量)并继续,如果为0,则进程将进入睡眠。检查数值、修改变量值、以及可能发生的睡眠操作是作为一个单一的、不可分割的原子操作完成的。保证一旦一个信号量开始,则在该操作完成或阻塞之前,其他进程均不可以访问该信号量。这种原子性,对于解决同步问题和避免竞争条件是决定必要的。所谓原子操作,是指一组相关联的操作要么不间断的执行,要么都不执行。

up操作对信号量的值加1。如果一个或者多个进程在该信号量上睡眠,无法完成一个先前的down操作,则由系统选择其中一个,并允许该进程完成它的down操作。

 用信号量解决丢失的wakeup问题,为确保信号量可以正确的工作,最重要的是采用一种不可分割的方式来实现它。通常是将down\up作为系统调用实现,而且操作系统只在执行以下操作时,暂时屏蔽全部中断:测试信号量、更新信号量以及在需要时使某个进程睡眠。由于这些动作只需要几条指令,所以屏蔽中断不会带来副作用。如果使用多个CPU,则每个信号量应由一个锁变量进行保护。

信号量的另一种用途是为了实现同步,信号量full和empty用来保证某种事件的顺序发送或者不发生。在本例中,它保证了当缓冲区满的时候生产者停止运行,以及当缓冲区空的时候消费者停止运行。

 

f、互斥量

 如果不需要使用信号量的计数能力,有时可以使用信号量的一个简化版本,称为互斥量,它仅仅适用于管理共享资源或一小段代码。

互斥量是可以处于两态之一的变量:解锁和加锁。这样,只需要一个二进制位表示它,不过实际上,常常使用一个整形量,0表示解锁,其他值表示加锁。互斥量使用的两个过程:当一个线程(或者进程)需要访问临界区时,它调用mutex_lock,如果该互斥量当前是解锁的(即临界区可用),此调用成功,调用线程可自由进入该临界区。

另一方面,如果该互斥量已经加锁,调用线程被阻塞,直到在临界区的线程完成并调用mutex_unlock。如果多个线程被阻塞在该互斥量上,将随机选择一个线程并允许它获得锁。

 

g、管程

一个管程是一个由过程、变量、以及数据结构等组成的一个集合,它们组成了一个特殊的模块或者软件包。进程可以在任何需要的时候调用管程中的过程,但它们不能在管程声明之外的过程中直接访问管程内的数据结构。

管程有一个很重要的特性,即任一时刻,管程中只能有一个活跃进程,这一特性使得管程能有效的完成互斥。管程是编程语言的组成成分,编译器知道它们的特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用。典型的处理方法是:当一个进程调用管程过程时,该过程的前几条指令会检查在管程中是否有其他活跃进程,如果有,调用进程将被挂起,直到另一个进程离开管程将其唤醒。如果没有活跃进程在使用管程,则该调用进程可以进入。

 

4、调度

当计算机系统是多道程序设计系统时,通常会有多个进程或者线程同时竞争CPU,只要有两个或者更多的进程处于就绪态,这种情况就会发生。如果只有一个CPU可以使用,那么就必须选择下一个要运行的进程。在操作系统中,完成选择工作的这一部分称为调度,该程序使用的算法称为调度算法。

尽管有些不同,但许多适用于进程调度的处理方法同时也适用于线程调度。当内核管理线程的时候,调度经常是按线程级别的,与线程所属的进程基本或根本没有关联。

 

a、批处理系统中的调度算法

先来先服务:

在所有调度算法中最简单的是非抢占式的先来先服务算法,使用该算法,进程按照它们请求CPU的顺序使用CPU。基本上,就是一个就绪进程的单一队列,早上,当第一个作业从外部进入系统,就立即开始并允许运行它所期望的时间。不会中断该作业,因为它需要很长的时间运行,当其他作业进入时,它们就被安排在队列的尾部。当正在运行的进程被堵塞时,队列中的第一个进程就接着运行,当被堵塞的进程变为就绪态时,它就像一个新来到的作业一样,被排到队列末尾。

 

最短作业优先:

它是适用于运行时间可以预知的另一个非抢占式的批处理算法。例如,一家保险公司,因为每天都做类似的工作,所以人们可以相当精确的预测处理1000个索赔的一批作业需要多少时间。当输入队列有若干个同等重要的作业被启动时,调度程序应该使用最短作业优先算法。

 

最短剩余时间优先:

最短作业优先的抢占式算法就是最短剩余时间优先算法,使用这个算法,调度程序总是选择剩余运行时间最短的那个算法。 

 

b、交互式系统中的调度

轮转调度:

一种最古老、最简单、最公平并且使用最广的算法是轮转算法。每个进程被分配一个时间段,称为时间片,即允许该进程在该时间段中运行。如果在时间片结束时,该进程还在运行,则将剥夺CPU被分配给另一进程。如果该进程在时间片结束之前被堵塞或者结束,则CPU立刻进行切换。时间片轮转调度很容易实现,调度程序所要做的就是维护一张可运行进程列表,当一个进程用完它的时间片之后,就被一到队列末尾。

时间片轮转调度唯一有趣的一点就是时间片的长度,从一个进程切换到另一个进程是需要一定时间进行事务处理的(保存和装入寄存器值及内存映像、更新各种表格和列表、清楚和重新调入内存高速缓存等),称为进程切换,也称作上下文切换。

时间片设置得太短会导致过多的进程切换,降低了CPU效率;而设置太长又可能引起对短的交互请求的响应时间变长,将它设置为20ms~~50ms是一个比较合理的折中。

 

优先级调度:

轮转调度做了一个隐含的假设,即所有的进程同等重要。而实际中即使是在只有一个用户的PC上,也会有多个进程,其中一些比另一些更重要。

为了防止高优先级别的进程无休止的运行下去,调度程序可以在每个时钟中断降低当前线程的优先级别,如果这个行为导致当前进程的优先级别低于次高优先级的进程,则进行线程切换。一个可以采用的方法是,每个进程可以被赋予一个允许运行的最大时间片,当这个时间片用完时,下一个次高优先级的进程开始运行。 

如果不对优先级别进行调整,则低优先级别的进程很可能会产生饥饿现象。

 

最短进程优先:

 对于批处理系统而言,由于最短作业优先常常伴随着最短响应时间,所以如果能够把它用于交互进程,那将是非常好的。在某种程度上,的确可以做到这一点,交互进程通常遵循下列模式:等待命令、执行命令、等待命令、执行命令,反复循环。如果我们将每一个命令的执行看作是一个独立的作业,则我们可以通过首先运行最短的作业来使响应时间最短。这里唯一的问题是,如何从当前可运行进程中找出最短的那一个进程。

一种办法是根据进程过去的行为进行推测,并执行估计运行时间最短的那一个。

 

还有其他的一些调度算法,但是不再做过多的描述。

 

c、线程调度

当若干进程都有多个线程时,就存在两个层次的并行:进程和线程。在这样的系统中调度处理有本质的区别,这取决于所支持的是用户级线程还是内核级线程(或者两者都支持)。

首先考虑用户级线程,由于内核并不知道有线程存在,所以内核还是和以前一样的操作,选择一个进程A,并给予A以时间片控制。A中的线程调度程序决定哪个线程运行,假设是a线程运行。由于多道线程并不存在时钟中断,所以这个线程可以按照其意愿任意运行多长时间。如果该线程用完了进程的全部时间片,内核就会选择另一个进程运行。

考虑到使用内核级线程的情形,内核选择一个特定的线程运行,它不用考虑这个线程属于哪个进程,对被选择的线程赋予一个时间片,如果超过了时间片,就强制挂起该线程。

用户级线程与内核级线程的差别在于性能。用户级线程切换只需要少量的机器指令,而内核级线程需要完整的上下文切换,修改内存映像,使告诉缓存失效,这导致了若干数量级的延迟。另一方面,使用内核级线程时,一旦线程阻塞在I/O上,就不需要像用户级线程中那样将整个进程挂起。

 

 

 

 

 


评论: