Linux的概念与体系

作者:Vamei 出处:http://www.cnblogs.com/vamei 欢迎转载,也请保留这段声明。谢谢!

其实在原创文章中都可以看见,我记录下来是为了方便加深记忆。

一、Linux简介

Linux kernel (内核):负责管理硬件,并为上层应用提供接口。Linux的有如下比较重要的机制: 用于储存数据以及管理权限的文件系统,用于IO的文本操作API,用于管理操作的进程,用于实现多任务运行的多线程,以及进程间通信(信号和网络)。

参考文章:http://www.cnblogs.com/vamei/archive/2012/09/04/2671103.html

二、Linux开机启动

   打开计算机电源,计算机会自动从主板的BIOS(Basic Input/Output System)读取其中所存储的程序。这一程序通常知道一些直接连接在主板上的硬件(硬盘,网络接口,键盘,串口,并口)。现在大部分的BIOS允许你从软盘、光盘或者硬盘中选择一个来启动计算机。

   下一步,计算机将从所选择的存储设备中读取起始的512 bytes(比如如果从光盘启动,光盘一开是的512 bytes)。这512 bytes叫做主引导记录MBR (master boot record)。MBR会告诉电脑从该设备的某一个分区(partition)来装载引导加载程序(boot loader)。Boot loader储存有操作系统(OS)的相关信息,比如操作系统名称,操作系统内核 (内核)所在位置等。常用的boot loader有GRUB和LILO。

   随后,boot loader会帮助加载内核(kernel)。内核实际上是一个用来操作计算机的程序,它是计算机操作系统的内核,主要的任务是管理计算机的硬件资源,充当软件和硬件的接口。操作系统上的任何操作都要通过内核传达给硬件。Windows和Linux各自有自己内核。

Linux内核:内核会首先预留自己运行所需的内存空间,然后通过驱动程序(driver)检测计算机硬件。这样,就可以知道自己有哪些硬件可用。随后,内核会启动一个init进程。它是Linux系统中的1号进程(Linux系统没有0号进程)。到此,内核就完成了在计算机启动阶段的工作,交接给init来管理。

init process:(根据boot loader的选项,Linux此时可以进入单用户模式(single user mode)。在此模式下,初始脚本还没有开始执行,我们可以检测并修复计算机可能存在的错误)随后,init会运行一系列的初始脚本(startup scripts),这些脚本是Linux中常见的shell scripts。这些脚本执行如下功能:设置计算机名称,时区,检测文件系统,挂载硬盘,清空临时文件,设置网络……当这些初始脚本,操作系统已经完全准备好了,只是,还没有人可以登录。init会给出登录(login)对话框,或者是图形化的登录界面。输入用户名(比如说vamei)和密码,DONE!在此后的过程中,你将以用户(user)vamei的身份操作电脑。此外,根据创建用户时的设定,Linux还会将用户归到某个组(group)中,比如可以是stupid组,或者是vamei组。

三、Linux文件管理

1、在Linux中,使用ls命令来显示目录下的所有文件,比如 $ls /home/vamei/doc

2、由于目录文件中都有.和..的条目,可以在路径中加入.或者..来表示当前目录或者父目录,比如/home/vamei/doc/..与/home/vamei等同。

3、在shell中,可以在命令行输入$pwd,查询到到当前工作目录。

4、当文件出现在一个目录文件中时,就把文件接入到文件系统中,称建立一个到文件的硬链接(hard link)。一个文件允许出现在多个目录中,这样,它就有多个硬链接。当硬链接的数目(link count)降为0时,文件会被Linux删除。所以很多时候,unlink与remove在Linux操作系统中是一个意思。由于软链接(soft link)的广泛使用(soft link不会影响link count,而且可以跨越文件系统),现在较少手动建立硬连接。

5、用ls命令可以查询文件信息(命令:$ls -l file.txt)。结果格式如下:

drwx------  2 root    root    16384  9月 20 17:15 lost+found

格式说明:

1)d表示文件类型,说明是目录文件,常规文件(file1.txt)是-

2随后有九个字符,为rwx------,它们用于表示文件权限。这九个字符分为三组,rwx, ---, ---,分别对应拥有者(owner),拥有组(owner group)和所有其他人(other)。第一组表示,如果我的名片上的用户身份证明我是该文件的拥有者,那么我就可以对该文件有读取(r),写入(w),执行(x)该文件的权限。第二组表示,如果我的名片上的组身份证明我所在的组是该文件的拥有组的一员,那么我没有该文件的任何权限。第三组表示,如果我的名片显示我既不是拥有者,也不是拥有组的一员,那么我我没有该文件的任何权限。当我想要进行一个读取操作时,Linux会先看我是否是拥有者下文会进一步解释拥有者和拥有组。

3后面的2是硬连接(hard link)数目(link count)。

4之后的root表示用户root是文件的拥有者(owner)。而后面的root文件的拥有组是组root。文件的拥有者和拥有组在文件创建时就附加在文件上(相当于给文件上锁,只有有合适名片的用户才能打开操作)。注意,root是Linux的超级用户,该用户拥有所有的文件。

5随后的16384表示文件大小,单位为字节(byte)。

69月 20 17:15表示文件的上一次写入的时间(modification time)。实际上在文件附加信息中还包含有文件的上一次读取时间(access time),没有显示出来。

6、Linux的软链接(soft link,也叫做symbolic link)就是linux的快捷方式。软链接本质上是一个文件,文件类型是symbolic link。在这个文件中,包含有链接指向的文件的绝对路径。当从这个文件读取数据时,linux会导向所指向的文件,然后从那个文件中读取(就像双击快捷方式的效果一样)。软链接可以方便的在任何地方建立,并指向任何一个绝对路径。软链接本身也是一个文件,也可以执行文件所可以进行的操作。当我们对软链接操作时,要注意我们是对软链接本身操作,还是对软链接指向的目标操作。如果是后者,则是操作跟随链接指引(follow the link)

7、umask

当创建文件时,比如使用touch,它会尝试将新建文件创建为权限666,也就是rw-rw-rw-。但操作系统要参照权限mask来看是否真正将文件创建为666。权限mask表示操作系统不允许设置的权限位,比如说037(----wxrwx)的权限mask意味着不允许设置group的wx位和other的rwx位。如果是这个权限mask的话,最终的文件权限是rw-r----- (group的w位和other的rw位被mask)。可以通过$umask 022的方式改变权限mask。

四、 Linux架构

参考:http://www.cnblogs.com/vamei/archive/2012/09/19/2692452.html

1、在命令行中输入$man 2 syscalls可以查看所有的系统调用。也可以通过$man 2 read来查看系统调用read()的说明。在这两个命令中的2都表示要在2类(系统调用类)中查询 (具体各个类是什么可以通过$man man看到)。

2系统调用提供的功能非常基础,所以使用起来很麻烦。一个简单的给变量分配内存空间的操作,就需要动用多个系统调用。Linux定义一些库函数(library routine)来将系统调用组合成某些常用的功能。

3shell是一个命令解释器(interpreter),它可以执行符合shell语法的文本。这样的文本叫做shell脚本(script)。shell也有很多种,最常见的是bash, 另外还有sh, csh, tcsh, ksh。它们出现的年代不同,所支持的功能也有差异。

五、Linux命令行与命令

参考:http://www.cnblogs.com/vamei/archive/2012/09/10/2676740.html

1、Linux命令行是运行在终端(terminal)的shell。所谓的命令,是我们在命令行输入的一串字符。shell负责理解并执行这些字符串。shell命令可以分为如下几类 1)可执行文件(executable file) 2)shell内建函数(built-in function) 3) 别名(alias)可以通过type命令来了解命令的类型:$type ls   $type cd

2、命令的构成:整个一行命令由空格分为三个部分(如$ls -l /home)。第一个为命令的名字ls,第二个-l是关键字,它告诉ls要列出每个文件的详细信息,第三个/home为参数,表示我所要列出的目录是/home。

3、以普通用户的身份登录,但在执行命令之前加上sudo, 以便临时以root的身份执行某条命令。

4、对于大多数的shell来说,都有命令补齐的功能。当在$的后面输入命令或文件名的一部分时,比如rmdir的rmd的时候,按Tab键,Linux会帮你打剩下的字符,补充成为rmdir。如果有多个相符的命令,连按两下Tab,Linux会显示所有的相符的命令。

5、了解一个陌生命令

1)了解命令的绝对路径:$which ls      which 在默认路径中搜索命令,返回该命令的绝对路径

2)$whereis ls    whereis 在相对比较大的范围搜索命令,返回该命令的绝对路径

3)$whatis ls     whatis 用很简短的一句话来介绍命令。

4)$man ls        man 查询简明的帮助手册(man可以说是我们了解Linux最好的百科全书,它不但可以告诉你Linux自带的命令的功能,还可以查询Linux的系统文件和系统调用。如果想要深入学习Linux,就必须要懂得如何用man来查询相关文档。)

5)$info ls       info 查询更详细的帮助信息

6)在shell中,可以用向上箭头来查看之前输入运行的命令。

7)$history       查询之前在命令行的操作。

8)当一个命令运行时,中途停止它时,可以用 Ctrl + c 。如果暂时停止,使用 Ctrl + z

六、Linux文件管理相关命令

参考:http://www.cnblogs.com/vamei/archive/2012/09/13/2682519.html

1)touch:$touch a.txt   如果a.txt不存在,生成一个新的空文档a.txt。如果a.txt存在,那么只更改该文档的时间信息。 

2)ls, mv, cp, rm, mkdir, rmdir:

   $cp a.txt ..  将a.txt复制到父目录的a.txt

   $rm -r /home/vamei   删除从/home/vamei向下的整个子文件系统。-r表示recursive, 是指重复删除的操作,/home/vamei文件夹为空,然后删除/home/vamei文件夹本身。($rm -rf /   会删除整个文件树。f的目的是告诉rm放心干,不用再确认了)

3) chmod, chown, chgrp

   $chmod 755 a.txt  755,对应三个组。7被分配给拥有者,5被分配给拥有组,最后一个5分配给其它用户。Linux规定: 4为有读取的权利,2为有写入的权利,1为有执行的权利。我们看到的7实际上是4 + 2 + 1,表示拥有者有读、写、执行三项权利。

4) wild card文件名通配表达式(filename pattern matching),Linux会找到符合表达式的文件名,然后用这些文件名作为参数传递给命令:

*   任意多个任意字符;?   任意一个字符;[kl]   字符k或者字符l;[0-4]   数字0到4字符中的一个;[b-e]   b到e字符中的一个;[^mnp]   一个字符,这个字符不是m,n,p

注意,当使用rm的时候,要格外小心。下面两个命令,只相差一个空格,但效果大为不同(第一个命令会删除当前目录下所有文件!):

$rm * .txt

$rm *.txt

七、Linux文本流

参考:http://www.cnblogs.com/vamei/archive/2012/09/14/2683756.html

1、文本流,标准输入,标准输出,标准错误

当Linux执行一个程序的时候,会自动打开三个流,标准输入(standard input),标准输出(standard output),标准错误(standard error)。比如打开命令行的时候,默认情况下,命令行的标准输入连接到键盘,标准输出和标准错误都连接到屏幕。

2、cat, echo, wc

$echo hello           echo的作用是将文本流导向标准输出。在这里,echo的作用就是将hello输出到屏幕上。

$echohello > a.txt   a.txt中会有hello这个文本。

3、>, >>, <, |

1)重新定向标准输出:

$ls > a.txt  这里的>提醒命令行变换文本流的方向了,让标准输出输出到屏幕变换到a.txt这个文件。此时,计算机会新建一个a.txt的文件,并将命令行的标准输出指向这个文件。

$ls >> a.txt 这里>>的作用也是重新定向标准输出。如果a.txt已经存在,ls产生的文本流会附加在a.txt的结尾,而不会像>那样每次都新建a.txt。

2)改变标准输入:<符号。比如cat命令,它可以从标准输入读入文本流并输出到标准输出:

$cat < a.txt  将cat标准输入指向a.txt,文本会从文件流到cat,然后再输出到屏幕上。

$cat < a.txt > b.txt  同时重新定向标准输出,这样,a.txt的内容就复制到了b.txt中。

3)同时重新定向标准输出和标准错误:>&符号

$cd void > a.txt  假设我们并没有一个目录void。会在屏幕上返回错误信息。因为此时标准错误依然指向屏幕。

$cd void >& a.txt 错误信息被导向a.txt。

4) 重新定向标准错误,可以使用2>:

$cd void 2> a.txt > b.txt 标准错误对应的总是2号,所以有以上写法。标准错误输出到a.txt,标准输出输出到b.txt。

5)管道(pipe)

管道可以将一个命令的输出导向另一个命令的输入,从而让两个(或者更多命令)像流水线一样连续工作,不断地处理文本流。

在命令行中,我们用|表示管道:

$cat < a.txt | wc  wc命令代表word count,用于统计文本中的以及字符的总数。a.txt中的文本先流到cat,然后从cat的标准输出流到wc的标准输入,从而让wc知道自己要处理的是a.txt这个字符串。

八、 Linux进程基础

参考:http://www.cnblogs.com/vamei/archive/2012/09/20/2694466.html

   当计算机开机的时候,内核(kernel)只建立了一个init进程。Linux内核并不提供直接建立新进程的系统调用剩下的所有进程都是init进程通过fork机制建立的。新的进程要通过老的进程复制自身得到,这就是fork。fork是一个系统调用。进程存活于内存中。当进程fork的时候,Linux在内存中开辟出一片新的内存空间给新的进程,并将老的进程空间中的内容复制到新的空间中,此后两个进程同时运行。

查询当前shell下的进程:

 ps -o pid,ppid,cmd

$pstree命令:显示整个进程树

1、子进程的终结(termination)

   子进程终结时,会通知父进程,并清空自己所占据的内存,并在内核里留下自己的退出信息(解释该进程为什么退出,exit code,如果顺利运行,为0;如果有错误或异常状况,为>0的整数)。父进程在得知子进程终结时,对该子进程使用wait系统调用。这个wait函数能从内核中取出子进程的退出信息,并清空该信息在内核中所占据的空间。但是,如果父进程早于子进程终结,子进程就会成为一个孤儿(orphand)进程。孤儿进程会被过继给init进程,init进程也就成了该进程的父进程。init进程负责该子进程终结时调用wait函数。当然,一个糟糕的程序也完全可能造成子进程的退出信息滞留在内核中的状况(父进程不对子进程调用wait函数),这样的情况下,子进程成为僵尸(zombie)进程。当大量僵尸进程积累时,内存空间会被挤占。

2、进程与线程(thread)

线程只是一种特殊的进程。多个线程之间可以共享内存空间和IO接口。所以,进程是Linux程序的唯一的实现方式。

九、Linux信号基础

参考:http://www.cnblogs.com/vamei/archive/2012/10/04/2711818.html

   信号(signal)是一种内核向进程传递信息的方式,用于系统管理相关的任务,比如通知进程终结、中止或者恢复等等。相对于其他的进程间通信方式(interprocess communication, 比如说pipe, shared memory),信号所能传递的信息比较粗糙。由于传递的信息量少,信号也便于管理和使用。

   信号是由内核(kernel)管理的。信号的产生方式可以是内核自身产生的,比如出现硬件错误,内核需要通知某一进程;也可以是其它进程产生的,发送给内核,再由内核传递给目标进程。内核中针对每一个进程都有一个表存储相关信息。当内核需要将信号传递给某个进程时,就在该进程相对应的表中写入信号,这样,就生成(generate)了信号。当该进程执行系统调用时,在系统调用完成后退出内核时,都会查看信箱里的信息。如果有信号,进程会执行对应该信号的操作(signal action, 也叫做信号处理signal disposition),叫做执行(deliver)信号。从信号的生成到信号的传递的时间,信号处于等待(pending)状态。也可以设计程序,让其生成的进程阻塞(block)某些信号(让信号始终处于等待状态,直到进程取消阻塞(unblock)或者无视信号

1、常见信号(信号所传递的每一个整数都被赋予了特殊的意义,并有一个信号名对应该整数

1)SIGINT   当键盘按下CTRL+C从shell中发出信号,信号被传递给shell中前台运行的进程,对应该信号的默认操作是中断 (INTERRUPT) 该进程

2)SIGQUIT  当键盘按下CTRL+\从shell中发出信号,信号被传递给shell中前台运行的进程,对应该信号的默认操作是退出 (QUIT) 该进程。

3)SIGTSTP  当键盘按下CTRL+Z从shell中发出信号,信号被传递给shell中前台运行的进程,对应该信号的默认操作是暂停 (STOP) 该进程。

4)SIGCONT  用于通知暂停的进程继续

5)SIGALRM  起到定时器的作用,通常是程序在一定的时间之后才生成该信号。

  $man 7 signal  命令用于查阅更多的信号。

2、在shell中使用信号

1)$ping localhost  在shell中运行ping

2)通过CTRL+Z来将SIGTSTP传递给该进程。shell中显示:

[1]+  Stopped                 ping localhost

3)使用$ps来查询ping进程的PID

4)$kill -SIGCONT  7471  在shell中通过$kill命令来向某个进程发出信号来传递SIGCONT信号给ping进程。

3、信号处理( signal dispositon),当进程决定执行信号的时候,有下面几种可能:

1) 无视(ignore)信号,信号被清除,进程本身不采取任何特殊的操作

2) 默认(default)操作。每个信号对应有一定的默认操作。比如上面SIGCONT用于继续进程。

3) 自定义操作。也叫做获取 (catch) 信号。执行进程中预设的对应于该信号的操作。

进程会采取哪种操作,要根据该进程的程序设计。特别是获取信号的情况,程序往往会设置一些比较长而复杂的操作(通常将这些操作放到一个函数中)。

十、Linux进程关系

参考:http://www.cnblogs.com/vamei/archive/2012/10/07/2713023.html

1、进程组(precess group)

每个进程都会属于一个进程组,每个进程组中可以包含多个进程。进程组会有一个进程组领导进程 (process group leader),领导进程的PID成为进程组的ID (process group ID, PGID),以识别进程组。

2、会话(session)

     在shell支持工作控制(job control)的前提下,多个进程组还可以构成一个会话 (session)。bash(Bourne-Again shell)支持工作控制,而sh(Bourne shell)并不支持。会话是由其中的进程建立的,该进程叫做会话的领导进程(session leader)。会话领导进程的PID成为识别会话的SID(session ID)。会话中的每个进程组称为一个工作(job)。会话可以有一个进程组成为会话的前台工作(foreground),而其他的进程组是后台工作(background)。每个会话可以连接一个控制终端(control terminal)。当控制终端有输入输出时,都传递给该会话的前台进程组。由终端产生的信号,比如CTRL+Z, CTRL+\,会传递到前台进程组。

1)一个命令可通过在末尾加上&方式让它在后台运行:$ping localhost > log &  界面显示[1] 7639,其中括号中的1表示工作号,而7639为PGID

2)$ps -o pid,pgid,ppid,sid,tty,comm  查询更加详细的信息(tty表示控制终端)

3)信号可以发送给工作组

 $kill -SIGTERM -10141  发送给PGID(通过在PGID前面加-来表示是一个PGID而不是PID)

或者 $kill -SIGTERM %1  发送给工作1(%1)

4)一个工作可以通过$fg从后台工作变为前台工作:$fg %1

若工作在后台,我们无法对命令进行输入,直到fg将工作带入前台,才能执行。在cat输入完成后,按下CTRL+D来通知shell输入结束。

十一、Linux用户

参考:http://www.cnblogs.com/vamei/archive/2012/10/07/2713593.html

1、每个进程会维护有如下6个ID:

真实身份: real UID,       real GID

有效身份: effective UID,  effective GID

存储身份:saved UID,      saved GID

其中,真实身份是我们登录使用的身份,有效身份是当该进程真正去操作文件时所检查的身份,存储身份较为特殊。当进程fork的时候,真实身份和有效身份都会复制给子进程。当Linux完成开机启动之后,init进程会执行一个login的子进程。我们将用户名和密码传递给login子进程。login在查询了/etc/passwd和/etc/shadow,并确定了其合法性之后,运行(利用exec)一个shell进程,shell进程真实身份被设置成为该用户的身份。由于此后fork此shell进程的子进程都会继承真实身份,所以该真实身份会持续下去,直到我们登出并以其他身份再次登录(当我们使用su成为root的时候,实际上就是以root身份再次登录,此后真实身份成为root)。

2、“最小权限”原则
收缩进程所享有的特权 以防进程滥用特权

十二、Linux从程序到进程

每个进程空间按照如下方式分为不同区域:

内存空间

Text区域用来储存指令(instruction),说明每一步的操作。Global Data用于存放全局变量,栈(Stack)用于存放局部变量,堆(heap)用于存放动态变量 (dynamic variable. 程序利用malloc系统调用,直接从内存中为dynamic variable开辟空间)。TextGlobal data在进程一开始的时候就确定了,并在整个进程中保持固定大小

 

栈(Stack)(stack frame)为单位。当程序调用函数的时候,比如main()函数中调用inner()函数,stack会向下增长一帧。帧中存储该函数的参数局部变量,以及该函数的返回地址(return address)。此时,计算机将控制权从main()转移到inner(),inner()函数处于激活(active)状态。位于栈最下方的帧,和全局变量一起,构成了当前的环境(context)。激活函数可以从环境中调用需要的变量。典型的编程语言都只允许你使用位于stack最下方的帧 ,而不允许你调用其它的帧 (这也符合stack结构“先进后出”的特征。但也有一些语言允许你调用栈的其它部分,相当于允许你在运行inner()函数的时候调用main()中声明的局部变量,比如Pascal)。当函数又进一步调用另一个函数的时候,一个新的帧会继续增加到栈的下方,控制权转移到新的函数中。当激活函数返回的时候,会从栈中弹出(pop,读取并从栈中删除)该帧,并根据帧中记录的返回地址,将控制权交给返回地址所指向的指令(比如从inner()函数中返回,继续执行main()中赋值给main2的操作)。

下图是栈在运行过程中的变化。箭头表示栈的增长方向。每个方块代表一帧。开始的时候我们有一个为main()服务的帧,随着调用inner(),我们为inner()增加一个帧。在inner()返回时,我们再次只有main()的帧,直到最后main()返回,其返回地址为空,所以进程结束。

stack变化

在进程运行的过程中,通过调用和返回函数,控制权不断在函数间转移。进程可以在调用函数的时候,原函数的帧中保存有在我们离开时的状态,并为新的函数开辟所需的帧空间。在调用函数返回时,该函数的帧所占据的空间随着帧的弹出而清空。进程再次回到原函数的帧中保存的状态,并根据返回地址所指向的指令继续执行。上面过程不断继续,栈不断增长或减小,直到main()返回的时候,栈完全清空,进程结束。

 

当程序中使用malloc的时候,堆(heap)向上增长,其增长的部分就成为malloc从内存中分配的空间。malloc开辟的空间会一直存在,直到我们用free系统调用来释放,或者进程结束。一个经典的错误是内存泄漏(memory leakage), 就是指我们没有释放不再使用的堆空间,导致堆不断增长,而内存可用空间不断减少。

栈和堆的大小则会随着进程的运行增大或者变小。当栈和堆增长到两者相遇时候,也就是内存空间图中的蓝色区域(unused area)完全消失的时候,再无可用内存。进程会出现栈溢出(stack overflow)的错误,导致进程终止。在现代计算机中,内核一般会为进程分配足够多的蓝色区域,如果清理及时,栈溢出很容易避免。即便如此,内存负荷过大,依然可能出现栈溢出的情况。我们就需要增加物理内存了。

Stack overflow可以说是最出名的计算机错误了,所以才有IT网站(stackoverflow.com)以此为名。

 

在高级语言中,这些内存管理的细节对于用户来说不透明。在编程的时候,我们只需要记住上一节中的变量作用域就可以了。但在想要写出复杂的程序或者debug的时候,我们就需要相关的知识了。

进程附加信息

除了上面的信息之外,每个进程还要包括一些进程附加信息,包括PID,PPID,PGID(参考Linux进程基础以及Linux进程关系)等,用来说明进程的身份、进程关系以及其它统计信息。这些信息并不保存在进程的内存空间中。内核会为每个进程在内核自己的空间中分配一个变量(task_struct结构体)以保存上述信息。内核可以通过查看自己空间中的各个进程的附加信息就能知道进程的概况,而不用进入到进程自身的空间 (就好像我们可以通过门牌就可以知道房间的主人是谁一样,而不用打开房门)。每个进程的附加信息中有位置专门用于保存接收到的信号(正如我们在Linux信号基础中所说的“信箱”)。

fork & exec

现在,我们可以更加深入地了解forkexec(参考Linux进程基础)的机制了。当一个程序调用fork的时候,实际上就是将上面的内存空间,包括text, global data, heap和stack,又复制出来一个,构成一个新的进程,并在内核中为改进程创建新的附加信息 (比如新的PID,而PPID为原进程的PID)。此后,两个进程分别地继续运行下去。新的进程和原有进程有相同的运行状态(相同的变量值,相同的instructions...)。我们只能通过进程的附加信息来区分两者。

程序调用exec的时候,进程清空自身内存空间的text, global data, heap和stack,并根据新的程序文件重建text, global data, heap和stack (此时heap和stack大小都为0),并开始运行。

(现代操作系统为了更有效率,改进了管理fork和exec的具体机制,但从逻辑上来说并没有差别。具体机制请参看Linux内核相关书籍)

这一篇写了整合了许多东西,所以有些长。这篇文章主要是概念性的,许多细节会根据语言和平台乃至于编译器的不同而有所变化,但大体上,以上的概念适用于所有的计算机进程(无论是Windows还是UNIX)。更加深入的内容,包括线程(thread)、进程间通信(IPC)等,都依赖于这里介绍的内容。

十三、Linux多线程与同步

对于多线程程序来说,同步(synchronization)是指在一定的时间内只允许某一个线程访问某个资源 。而在此时间内,不允许其它的线程访问该资源。我们可以通过互斥锁(mutex)条件变量(condition variable)读写锁(reader-writer lock)来同步资源。

 

1) 互斥锁

互斥锁是一个特殊的变量,它有锁上(lock)和打开(unlock)两个状态。互斥锁一般被设置成全局变量。打开的互斥锁可以由某个线程获得。一旦获得,这个互斥锁会锁上,此后只有该线程有权打开。其它想要获得互斥锁的线程,会等待直到互斥锁再次打开的时候。我们可以将互斥锁想像成为一个只能容纳一个人的洗手间,当某个人进入洗手间的时候,可以从里面将洗手间锁上。其它人只能在互斥锁外面等待那个人出来,才能进去。在外面等候的人并没有排队,谁先看到洗手间空了,就可以首先冲进去。

上面的问题很容易使用互斥锁的问题解决,每个线程的程序可以改为:

复制代码
/*mu is a global mutex*/

while (1) { /*infinite loop*/ mutex_lock(mu);       /*aquire mutex and lock it, if cannot, wait until mutex is unblocked*/ if (i != 0) i = i - 1; else { printf("no more tickets"); exit(); } mutex_unlock(mu);     /*release mutex, make it unblocked*/ }
复制代码

第一个执行mutex_lock()的线程会先获得mu。其它想要获得mu的线程必须等待,直到第一个线程执行到mutex_unlock()释放mu,才可以获得mu,并继续执行线程。所以线程在mutex_lock()和mutex_unlock()之间的操作时,不会被其它线程影响,就构成了一个原子操作

需要注意的时候,如果存在某个线程依然使用原先的程序 (即不尝试获得mu,而直接修改i),互斥锁不能阻止该程序修改i,互斥锁就失去了保护资源的意义。所以,互斥锁机制需要程序员自己来写出完善的程序来实现互斥锁的功能。我们下面讲的其它机制也是如此。

 

2) 条件变量

条件变量是另一种常用的变量。它也常常被保存为全局变量,并和互斥锁合作。

 

假设这样一个状况: 有100个工人,每人负责装修一个房间。当有10个房间装修完成的时候,老板就通知相应的十个工人一起去喝啤酒。

我们如何实现呢?老板让工人在装修好房间之后,去检查已经装修好的房间数。但多线程条件下,会有竞争条件的危险。也就是说,其他工人有可能会在该工人装修好房子和检查之间完成工作。采用下面方式解决:

复制代码
/*mu: global mutex, cond: global codition variable, num: global int*/
mutex_lock(mu) num
= num + 1; /*worker build the room*/ if (num <= 10) { /*worker is within the first 10 to finish*/ cond_wait(mu, cond);     /*wait*/ printf("drink beer"); } else if (num = 11) { /*workder is the 11th to finish*/ cond_broadcast(mu, cond);        /*inform the other 9 to wake up*/ } mutex_unlock(mu);
复制代码

上面使用了条件变量。条件变量除了要和互斥锁配合之外,还需要和另一个全局变量配合(这里的num, 也就是装修好的房间数)。这个全局变量用来构成各个条件。

 

具体思路如下。我们让工人在装修好房间(num = num + 1)之后,去检查已经装修好的房间数( num < 10 )。由于mu被锁上,所以不会有其他工人在此期间装修房间(改变num的值)。如果该工人是前十个完成的人,那么我们就调用cond_wait()函数。
cond_wait()做两件事情,一个是释放mu,从而让别的工人可以建房。另一个是等待,直到cond的通知。这样的话,符合条件的线程就开始等待。

当有通知(第十个房间已经修建好)到达的时候,condwait()会再次锁上mu。线程的恢复运行,执行下一句prinft("drink beer") (喝啤酒!)。从这里开始,直到mutex_unlock(),就构成了另一个互斥锁结构。

那么,前面十个调用cond_wait()的线程如何得到的通知呢?我们注意到elif if,即修建好第11个房间的人,负责调用cond_broadcast()。这个函数会给所有调用cond_wait()的线程放送通知,以便让那些线程恢复运行。

 

条件变量特别适用于多个线程等待某个条件的发生。如果不使用条件变量,那么每个线程就需要不断尝试获得互斥锁并检查条件是否发生,这样大大浪费了系统的资源。

 

3) 读写锁

读写锁与互斥锁非常相似。r、RW lock有三种状态: 共享读取锁(shared-read)互斥写入锁(exclusive-write lock), 打开(unlock)。后两种状态与之前的互斥锁两种状态完全相同。

一个unlock的RW lock可以被某个线程获取R锁或者W锁。

如果被一个线程获得R锁,RW lock可以被其它线程继续获得R锁,而不必等待该线程释放R锁。但是,如果此时有其它线程想要获得W锁,它必须等到所有持有共享读取锁的线程释放掉各自的R锁。

如果一个锁被一个线程获得W锁,那么其它线程,无论是想要获取R锁还是W锁,都必须等待该线程释放W锁。

这样,多个线程就可以同时读取共享资源。而具有危险性的写入操作则得到了互斥锁的保护。

 

我们需要同步并发系统,这为程序员编程带来了难度。但是多线程系统可以很好的解决许多IO瓶颈的问题。比如我们监听网络端口。如果我们只有一个线程,那么我们必须监听,接收请求,处理,回复,再监听。如果我们使用多线程系统,则可以让多个线程监听。当我们的某个线程进行处理的时候,我们还可以有其他的线程继续监听,这样,就大大提高了系统的利用率。在数据越来越大,服务器读写操作越来越多的今天,这具有相当的意义。多线程还可以更有效地利用多CPU的环境。

十四、线程间通信

进程间通信方式可以分为两种:

  • 管道(PIPE)机制。在Linux文本流中,我们提到可以使用管道将一个进程的输出和另一个进程的输入连接起来,从而利用文件操作API来管理进程间通信。在shell中,我们经常利用管道将多个进程连接在一起,从而让各个进程协作,实现复杂的功能。
  • 传统IPC (interprocess communication)。我们主要是指消息队列(message queue),信号量(semaphore),共享内存(shared memory)。这些IPC的特点是允许多进程之间共享资源,这与多线程共享heap和global data相类似。由于多进程任务具有并发性 (每个进程包含一个进程,多个进程的话就有多个线程),所以在共享资源的时候也必须解决同步的问题 (参考Linux多线程与同步)。
管道与FIFO文件

一个原始的IPC方式是所有的进程通过一个文件交流。比如我在纸(文件)上写下我的名字和年纪。另一个人读这张纸,会知道我的名字和年纪。他也可以在同一张纸上写下他的信息,而当我读这张纸的话,同样也可以知道别人的信息。但是,由于硬盘读写比较慢,所以这个方式效率很低。那么,我们是否可以将这张纸放入内存中以提高读写速度呢?

Linux文本流中,我们已经讲解了如何在shell中使用管道连接多个进程。同样,许多编程语言中,也有一些命令用以实现类似的机制,比如在Python子进程中使用Popen和PIPE,在C语言中也有popen库函数来实现管道 (shell中的管道就是根据此编写的)。管道是由内核管理的一个缓冲区(buffer),相当于我们放入内存中的一个纸条。管道的一端连接一个进程的输出。这个进程会向管道中放入信息。管道的另一端连接一个进程的输入,这个进程取出被放入管道的信息。一个缓冲区不需要很大,它被设计成为环形的数据结构,以便管道可以被循环利用。当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。


从原理上,管道利用fork机制建立(参考Linux进程基础Linux从程序到进程),从而让两个进程可以连接到同一个PIPE上。最开始的时候,上面的两个箭头都连接在同一个进程Process 1上(连接在Process 1上的两个箭头)。当fork复制进程的时候,会将这两个连接也复制到新的进程(Process 2)。随后,每个进程关闭自己不需要的一个连接 (两个黑色的箭头被关闭; Process 1关闭从PIPE来的输入连接,Process 2关闭输出到PIPE的连接),这样,剩下的红色连接就构成了如上图的PIPE。

由于基于fork机制,所以管道只能用于父进程和子进程之间,或者拥有相同祖先的两个子进程之间 (有亲缘关系的进程之间)。为了解决这一问题,Linux提供了FIFO方式连接进程。FIFO又叫做命名管道(named PIPE)。

FIFO (First in, First out)为一种特殊的文件类型,它在文件系统中有对应的路径。当一个进程以读(r)的方式打开该文件,而另一个进程以写(w)的方式打开该文件,那么内核就会在这两个进程之间建立管道,所以FIFO实际上也由内核管理,不与硬盘打交道。之所以叫FIFO,是因为管道本质上是一个先进先出队列数据结构,最早放入的数据被最先读出来(好像是传送带,一头放货,一头取货),从而保证信息交流的顺序。FIFO只是借用了文件系统(file system, 参考Linux文件管理背景知识)来为管道命名。写模式的进程向FIFO文件中写入,而读模式的进程从FIFO文件中读出。当删除FIFO文件时,管道连接也随之消失。FIFO的好处在于我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间建立连接。

传统PIC

这几种传统IPC实际上有很悠久的历史,所以其实现方式也并不完善 (比如说我们需要某个进程负责删除建立的IPC)。一个共同的特征是它们并不使用文件操作的API。对于任何一种IPC来说,你都可以建立多个连接,并使用键值(key)作为识别的方式。我们可以在一个进程中中通过键值来使用的想要那一个连接 (比如多个消息队列,而我们选择使用其中的一个)。键值可以通过某种IPC方式在进程间传递(比如说我们上面说的PIPE,FIFO或者写入文件),也可以在编程的时候内置于程序中。

在几个进程共享键值的情况下,这些传统IPC非常类似于多线程共享资源的方式(参看Linux多线程与同步):

  • semaphoremutex类似,用于处理同步问题。我们说mutex像是一个只能容纳一个人的洗手间,那么semaphore就像是一个能容纳N个人的洗手间。其实从意义上来说,semaphore就是一个计数锁(我觉得将semaphore翻译成为信号量非常容易让人混淆semaphore与signal),它允许被N个进程获得。当有更多的进程尝试获得semaphore的时候,就必须等待有前面的进程释放锁。当N等于1的时候,semaphore与mutex实现的功能就完全相同。许多编程语言也使用semaphore处理多线程同步的问题。一个semaphore会一直存在在内核中,直到某个进程删除它。
  • 共享内存与多线程共享global data和heap类似。一个进程可以将自己内存空间中的一部分拿出来,允许其它进程读写。当使用共享内存的时候,我们要注意同步的问题。我们可以使用semaphore同步,也可以在共享内存中建立mutex或其它的线程同步变量来同步。由于共享内存允许多个进程直接对同一个内存区域直接操作,所以它是效率最高的IPC方式。

消息队列(message queue)与PIPE相类似。它也是建立一个队列,先放入队列的消息被最先取出。不同的是,消息队列允许多个进程放入消息,也允许多个进程取出消息。每个消息可以带有一个整数识别符(message_type)。你可以通过识别符对消息分类 (极端的情况是将每个消息设置一个不同的识别符)。某个进程从队列中取出消息的时候,可以按照先进先出的顺序取出,也可以只取出符合某个识别符的消息(有多个这样的消息时,同样按照先进先出的顺序取出)。消息队列与PIPE的另一个不同在于它并不使用文件API。最后,一个队列不会自动消失,它会一直存在于内核中,直到某个进程删除该队列。

 

多进程协作可以帮助我们充分利用多核和网络时代带来的优势。多进程可以有效解决计算瓶颈的问题。互联网通信实际上也是一个进程间通信的问题,只不过这多个进程分布于不同的电脑上。网络连接是通过socket实现的。由于socket内容庞大,所以我们不在这里深入。一个小小的注解是,socket也可以用于计算机内部进程间的通信。


十五、文件系统的实现

Linux文件管理从用户的层面介绍了Linux管理文件的方式。Linux有一个树状结构来组织文件。树的顶端为根目录(/),节点为目录,而末端的叶子为包含数据的文件。当我们给出一个文件的完整路径时,我们从根目录出发,经过沿途各个目录,最终到达文件。

我们可以对文件进行许多操作,比如打开和读写。在Linux文件管理相关命令中,我们看到许多对文件进行操作的命令。它们大都基于对文件的打开和读写操作。比如cat可以打开文件,读取数据,最后在终端显示:

$cat test.txt

 

对于Linux下的程序员来说,了解文件系统的底层组织方式,是深入进行系统编程所必备的。即使是普通的Linux用户,也可以根据相关的内容,设计出更好的系统维护方案。

 

存储设备分区

文件系统的最终目的是把大量数据有组织的放入持久性(persistant)的存储设备中,比如硬盘和磁盘。这些存储设备与内存不同。它们的存储能力具有持久性,不会因为断电而消失;存储量大,但读取速度慢。

 

观察常见存储设备。最开始的区域是MBR,用于Linux开机启动(参考Linux开机启动)。剩余的空间可能分成数个分区(partition)。每个分区有一个相关的分区表(Partition table),记录分区的相关信息。这个分区表是储存在分区之外的。分区表说明了对应分区的起始位置和分区的大小。

 

我们在Windows系统常常看到C分区、D分区等。Linux系统下也可以有多个分区,但都被挂载在同一个文件系统树上。

数据被存入到某个分区中。一个典型的Linux分区(partition)包含有下面各个部分:

 

分区的第一个部分是启动区(Boot block),它主要是为计算机开机服务的。Linux开机启动后,会首先载入MBR,随后MBR从某个硬盘的启动区加载程序。该程序负责进一步的操作系统的加载和启动。为了方便管理,即使某个分区中没有安装操作系统,Linux也会在该分区预留启动区。

启动区之后的是超级区(Super block)。它存储有文件系统的相关信息,包括文件系统的类型,inode的数目,数据块的数目。

随后是多个inodes,它们是实现文件存储的关键。在Linux系统中,一个文件可以分成几个数据块存储,就好像是分散在各地的龙珠一样。为了顺利的收集齐龙珠,我们需要一个“雷达”的指引:该文件对应的inode。每个文件对应一个inode。这个inode中包含多个指针,指向属于该文件各个数据块。当操作系统需要读取文件时,只需要对应inode的"地图",收集起分散的数据块,就可以收获我们的文件了。

 

 

最后一部分,就是真正储存数据的数据块们(data blocks)了。

 

inode简介

上面我们看到了存储设备的宏观结构。我们要深入到分区的结构,特别是文件在分区中的存储方式。

文件是文件系统对数据的分割单元。文件系统用目录来组织文件,赋予文件以上下分级的结构。在硬盘上实现这一分级结构的关键,是使用inode来虚拟普通文件和目录文件对象。

 

Linux文件管理中,我们知道,一个文件除了自身的数据之外,还有一个附属信息,即文件的元数据(metadata)。这个元数据用于记录文件的许多信息,比如文件大小,拥有人,所属的组,修改日期等等。元数据并不包含在文件的数据中,而是由操作系统维护的。事实上,这个所谓的元数据就包含在inode中。我们可以用$ls -l filename来查看这些元数据。正如我们上面看到的,inode所占据的区域与数据块的区域不同。每个inode有一个唯一的整数编号(inode number)表示。

 

在保存元数据,inode是“文件”从抽象到具体的关键。正如上一节中提到的,inode储存由一些指针,这些指针指向存储设备中的一些数据块,文件的内容就储存在这些数据块中。当Linux想要打开一个文件时,只需要找到文件对应的inode,然后沿着指针,将所有的数据块收集起来,就可以在内存中组成一个文件的数据了。

 数据块在1, 32, 0, ...

inode并不是组织文件的唯一方式。最简单的组织文件的方法,是把文件依次顺序的放入存储设备,DVD就采取了类似的方式。但如果有删除操作,删除造成的空余空间夹杂在正常文件之间,很难利用和管理。

复杂的方式可以使用链表,每个数据块都有一个指针,指向属于同一文件的下一个数据块。这样的好处是可以利用零散的空余空间,坏处是对文件的操作必须按照线性方式进行。如果想随机存取,那么必须遍历链表,直到目标位置。由于这一遍历不是在内存进行,所以速度很慢。

FAT系统是将上面链表的指针取出,放入到内存的一个数组中。这样,FAT可以根据内存的索引,迅速的找到一个文件。这样做的主要问题是,索引数组的大小与数据块的总数相同。因此,存储设备很大的话,这个索引数组会比较大。

inode既可以充分利用空间,在内存占据空间不与存储设备相关,解决了上面的问题。但inode也有自己的问题。每个inode能够存储的数据块指针总数是固定的。如果一个文件需要的数据块超过这一总数,inode需要额外的空间来存储多出来的指针。

 

inode示例 

在Linux中,我们通过解析路径,根据沿途的目录文件来找到某个文件。目录中的条目除了所包含的文件名,还有对应的inode编号。当我们输入$cat /var/test.txt时,Linux将在根目录文件中找到var这个目录文件的inode编号,然后根据inode合成var的数据。随后,根据var中的记录,找到text.txt的inode编号,沿着inode中的指针,收集数据块,合成text.txt的数据。整个过程中,我们参考了三个inode:根目录文件,var目录文件,text.txt文件的inodes。

在Linux下,可以使用$stat filename,来查询某个文件对应的inode编号。

 

在存储设备中实际上存储为:

 

当我们读取一个文件时,实际上是在目录中找到了这个文件的inode编号,然后根据inode的指针,把数据块组合起来,放入内存供进一步的处理。当我们写入一个文件时,是分配一个空白inode给该文件,将其inode编号记入该文件所属的目录,然后选取空白的数据块,让inode的指针指像这些数据块,并放入内存中的数据。

 

文件共享

在Linux的进程中,当我们打开一个文件时,返回的是一个文件描述符。这个文件描述符是一个数组的下标,对应数组元素为一个指针。有趣的是,这个指针并没有直接指向文件的inode,而是指向了一个文件表格,再通过该表格,指向加载到内存中的目标文件的inode。如下图,一个进程打开了两个文件。

可以看到,每个文件表格中记录了文件打开的状态(status flags),比如只读,写入等,还记录了每个文件的当前读写位置(offset)。当有两个进程打开同一个文件时,可以有两个文件表格,每个文件表格对应的打开状态和当前位置不同,从而支持一些文件共享的操作,比如同时读取。

要注意的是进程fork之后的情况,子进程将只复制文件描述符的数组,而和父进程共享内核维护的文件表格和inode。此时要特别小心程序的编写。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值