基础知识
一个可执行文件就是一个菜谱。进程是执行程序的过程,类似于按照食谱,真正去做菜的过程。
当计算机开机的时候,内核(kernel)只建立了一个init进程。剩下的所有进程都是init进程通过fork机制建立的。新的进程要通过老的进程复制自身得到,这就是fork。fork是一个系统调用。
进程存活于内存中。Linux在内存中开辟出一片新的内存空间给新的进程,并将老的进程空间中的内容复制到新的空间中,此后两个进程同时运行。
老进程成为新进程的父进程(parent process),新进程就是老的进程的子进程(child process)。一个进程除了有一个PID之外,还会有一个PPID(parent PID)来存储的父进程PID。
fork通常作为一个函数被调用。这个函数会有两次返回,将子进程的PID返回给父进程,0返回给子进程。子进程总可以查询自己的PPID来知道自己的父进程是谁。
程序会在调用fork之后,设计一个if选择结构:
当PID等于0时,说明该进程为子进程,那么让它执行某些指令,在当前空间执行;
当PID为一个正整数时,说明为父进程,则执行另外一些指令。
由此,就可以在子进程建立之后,让它执行与父进程不同的功能。
#Only works on Unix/Luinx/Mac
import os
print('Process (%s) start...' % os.getpid())
pid = os.fork()
if pid == 0:
print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
#返回0时,当前为子进程,getpid子进程id,getppid父进程id
else:
print('I (%s) just created a child process (%s).' % (os.getpid(), pid)) #fork返回不为0时,是父进程返回的子进程id,
# 当前为父进程,pid返回子进程的id,os.getpid()返回当前进程即父进程id
子进程的终结(termination)
进程与线程
Unix中,进程与线程有联系但是不同,但在Linux中,线程只是一种特殊的进程。多个线程可以共享内存空间和IO接口,所以,进程是Linux程序的唯一实现方式
文件流
系统运行时,数据并不是在一个文件里定居。数据会在CPU的指挥下不断地流动,就好像一个勤劳的上班族。有时数据需要到办公室上班,因此被读入到内存,有时会去酒店休假,传送到外部设备。有的时候,数据需要搬个家,转移到另一个文件。在这样跑来跑去的过程中,数据像是排着队走路的人流,我们叫它文本流(text stream,或者byte stream)。
然而,计算机不同设备之间的连接方法差异很大,从内存到文件的连接像是爬山,从内存到外设像是游过一条河。为此,Unix定义了流 (stream),作为连接操作系统各处的公路标准。有了“流”,无论是从内存到外设,还是从内存到文件,所有的数据公路都是相同的格式。
标准输入、标准输出、标准错误与错误
当Unix执行一个程序的时候,会自动打开三个流,标准输入(standard input),标准输出(standard output),标准错误(standard error)。
让文本流流到另一个文件,可以采用重新定向(redirect)的机制:
$ ls > a.txt
重新定向标准输出。这里的>就是提醒命令行,让它知道要变换文本流方向…此时,计算机会新建一个a.txt的文件,并将命令行的标准输出指向这个文件。
$ ls >> a
这里>>的作用也是重新定向标准输出。如果a.txt已经存在的话,ls产生的文本流会附加在a.txt的结尾.
命令echo:
$ echo IamMojian
echo的作用是将 文本流 导向 标准输出。这里,echo的作用就是将IamMojian输出到屏幕上。
如果是:
$ echo IamMojian > a.txt
a.txt中就会有IamVamei这个文本。
可以用<符号来改变标准输入。比如cat命令,它可以从标准输入读入文本流,并输出到标准输出:
$ cat < a.txt
将cat标准输入指向a.txt,文本会从文件流到cat,然后再输出到屏幕上。
还可以同时重新定向标准输出:
$ cat < a.txt > b.txt
a.txt的内容就复制到了b.txt中。
还可以使用>&来同时 重新定向 标准输出和标准错误。假设我们并没有一个目录void。那么
$ cd void > a.txt
会在屏幕上返回错误信息。因为此时标准错误依然指向屏幕。当我们使用:
$ cd void >& a.txt
错误信息将被导向a.txt
如果只想重新定向标准错误,可以使用2>:
$ cd void 2> a.txt > b.txt
标准错误对应的总是2号,所以有以上写法。标准错误输出到a.txt,标准输出输出到b.txt。
管道(pipe)
管道可以将一个命令的输出导向另一个命令的输入,从而让两个(或者更多命令)像流水线一样连续工作,不断地处理文本流。在命令行中,我们用|表示管道:
$ cat < a.txt | wc
wc命令代表word count,用于统计文本中的行、词以及字符的总数。
a.txt中的文本先流到cat,然后从cat的标准输出流到wc的标准输入,从而让wc知道自己要处理的是a.txt这个字符串。
进程如何使用内存
当程序文件运行为进程时,进程在内存中获得空间。
每个进程空间按照如下方式分为不同区域:
text和Global data在进程一开始的时候就确定了,并在整个进程中保持固定大小。
动态变量(dynamic variable)程序利用malloc系统调用,直接从内存中为其开辟空间。当程序中使用malloc的时候,堆(heap)会向上增长,其增长的部分就成为malloc从内存中分配的空间。malloc开辟的空间会一直存在,直到我们用free系统调用来释放,或者进程结束。一个经典的错误是内存泄漏(memory leakage), 就是指我们没有释放不再使用的堆空间,导致堆不断增长,而内存可用空间不断减少。
栈(Stack)以帧(stack frame)为单位。当程序调用函数的时候,stack会向下增长一帧。
* 帧中存储该函数的参数和局部变量,以及该函数的返回地址(return address)。
位于栈最下方的帧,和全局变量一起,构成了当前的环境(context)。
计算机将控制权转移到另外函数上,另外的函数为激活状态。
激活函数可以从环境中调用需要的变量。典型的编程语言都只允许你使用位于stack最下方的帧 ,而不允许你调用其它的帧(这也符合stack结构“先进后出”的特征)。
当函数又进一步调用另一个函数的时候,一个新的帧会继续增加到栈的下方,控制权转移到新的函数中。
当激活函数返回的时候,会从栈中弹出(pop,读取并从栈中删除)该帧,并根据帧中记录的返回地址,将控制权交给返回地址所指向的指令。
在进程运行的过程中,通过调用和返回函数,控制权不断在函数间转移。进程可以在调用函数的时,原函数的帧中保存有在我们离开时的状态,并为新的函数开辟所需的帧空间。在调用函数返回时,该函数的帧所占据的空间随着帧的弹出而清空。进程再次回到原函数的帧中保存的状态,并根据返回地址所指向的指令继续执行。上面过程不断继续,栈不断增长或减小,直到main()返回的时候,栈完全清空,进程结束。
栈和堆的大小则会随着进程的运行增大或者变小。当栈和堆增长到两者相遇时候,也就是内存空间图中的蓝色区域(unused area)完全消失的时候,再无可用内存。进程会出现栈溢出(stack overflow)的错误,导致进程终止。
在现代计算机中,内核一般会为进程分配足够多的蓝色区域,如果清理及时,栈溢出很容易避免。即便如此,内存负荷过大,依然可能出现栈溢出的情况。我们就需要增加物理内存了。
进程附加信息
除了上面的信息之外,每个进程还要包括一些进程附加信息,包括PID,PPID,PGID等,用来说明进程的身份、进程关系以及其它统计信息。这些信息并不保存在进程的内存空间中。内核会为每个进程在内核自己的空间中分配一个变量(task_struct结构体)以保存上述信息。内核可以通过查看自己空间中的各个进程的附加信息就能知道进程的概况,而不用进入到进程自身的空间。每个进程的附加信息中有位置专门用于保存接收到的信号。
fork & exec
当一个程序调用fork的时候,实际上就是将上面的内存空间,包括text, global data, heap和stack,又复制出来一个,构成一个新的进程,并在内核中为改进程创建新的附加信息。此后,两个进程分别地继续运行下去。新的进程和原有进程有相同的运行状态(相同的变量值,相同的instructions...)。我们只能通过进程的附加信息来区分两者。
程序调用exec的时候,进程清空自身内存空间的text, global data, heap和stack,并根据新的程序文件重建text, global data, heap和stack (此时heap和stack大小都为0),并开始运行。
多线程
多任务可以由多进程完成,也可以由一个进程内的多线程完成。
单线程程序只有一个控制权的存在,当函数被调用时,该函数获得控制权,成为激活函数,然后运行该函数中的指令。其他函数并不运行。
多线程就是允许一个进程内存在多个控制权,以便让多个函数同时处于激活状态,从而让多个函数的操作同时运行。即使是单CPU的计算机,也可以通过不停地在不同线程的指令间切换,从而造成多线程同时运行的效果。
一个栈,只有最下方的帧可被读写。相应的,也只有该帧对应的那个函数被激活,为了实现多线程,我们必须绕开栈的限制。
为此,创建一个新的线程时,为这个线程建一个新的 栈 。每个 栈 对应一个线程。当某个 栈 执行到全部弹出时,对应线程完成任务,并收工。
所以,多线程的进程在内存中有多个栈。多个栈之间以一定的空白区域隔开,以备栈的增长(任何一个空白区域被填满都会导致stack overflow的问题)。
每个线程可调用自己栈最下方的帧中的参数和变量,并与其它线程共享内存中的Text,heap和global data区域。
并发
多线程相当于一个并发(concunrrency)系统。并发系统一般同时执行多个任务。如果多个任务可以共享资源,特别是同时写入某个变量的时候,就需要解决同步的问题。
在并发情况下,指令执行的先后顺序由内核决定。同一个线程内部,指令按照先后顺序执行,但不同线程之间的指令很难说清除哪一个会先执行。如果运行的结果依赖于不同线程执行的先后的话,那么就会造成竞争条件(race condition)。应该尽量避免竞争条件的形成,最常见的解决竞争条件的方法是将原先分离的两个指令构成不可分隔的一个原子操作(atomic operation),而其它任务不能插入到原子操作中。
多线程同步
对于多线程程序来说,同步(synchronization)是指在一定的时间内只允许某一个线程访问某个资源 。而在此时间内,不允许其它的线程访问该资源。我们可以通过互斥锁(mutex),条件变量(condition variable)和读写锁(reader-writer lock)来同步资源。
1.互斥锁
互斥锁是一个特殊的变量,它有锁上(lock)和打开(unlock)两个状态。互斥锁一般被设置成全局变量。打开的互斥锁可以由某个线程获得。
我们可以将互斥锁想像成为一个只能容纳一个人的洗手间,当某个人进入洗手间的时候,可以从里面将洗手间锁上。其它人只能在互斥锁外面等待那个人出来,才能进去。在外面等候的人并没有排队,谁先看到洗手间空了,就可以首先冲进去。
2.条件变量
条件变量是另一种常用的变量。它也常常被保存为全局变量,并和互斥锁合作。
条件变量特别适用于多个线程等待某个条件的发生。如果不使用条件变量,那么每个线程就需要不断尝试获得互斥锁并检查条件是否发生,这样大大浪费了系统的资源。
3.读写锁
RW lock有三种状态: 共享读取锁(shared-read), 互斥写入锁(exclusive-write lock), 打开(unlock)。后两种状态与之前的互斥锁两种状态完全相同。
一个打开的 RW lock 可以被某个线程获取 R锁 或者 W锁。
若被一个线程获取了R锁,则其他线程可以继续获取R锁,而不必等待该线程释放R锁。但若有其他线程要获取W锁,必须等到所有 持有共享读取锁 的线程释放掉R锁。
若被一个线程获取了W锁,其他线程,都必须等待该线程释放W锁,才能继续获取。
这样,多个线程就可以同时读取共享资源。而且,具有危险性的写入操作则得到了互斥锁的保护。
其他概念:
计算机运行过程中,并发、无序、大量的进程在使用有限、独占、不可抢占的资源,由于进程无限,资源有限,产生矛盾,这种矛盾称为竞争(Race)。
由于两个或者多个进程竞争使用不能被同时访问的资源,使得这些进程有可能因为时间上推进的先后原因而出现问题,这叫做竞争条件(Race Condition)。
竞争条件分为两类:
-Mutex(互斥):两个或多个进程彼此之间没有内在的制约关系,但是由于要抢占使用某个临界资源(不能被多个进程同时使用的资源,如打印机,变量)而产生制约关系。
-Synchronization(同步):两个或多个进程彼此之间存在内在的制约关系(前一个进程执行完,其他的进程才能执行),如严格轮转法。
condition variable:条件变量的作用是用于多线程之间关于共享数据状态变化的通信。当一个动作需要另外一个动作完成时才能进行,即:当一个线程的行为依赖于另外一个线程对共享数据状态的改变时,这时候就可以使用条件变量