进程与线程
进程(process)是指在系统中正在运行的一个应用程序,是系统资源分配的基本单位,在内存 中有其完备的数据空间和代码空间,拥有完整的虚拟空间地址。一个进程所拥有的数据和变量只 属于它自己。
线程(thread)是进程内相对独立的可执行单元,所以也被称为轻量进程(lightweight processes );是操作系统进行任务调度的基本单元。它与父进程的其它线程共享该进程所拥有的全部代码 空间和全局变量,但拥有独立的堆栈(即局部变量对于线程来说是私有的)。
1.1 线程和进程的区别:
- 线程是进程的一部分,所以线程有的时候被称为是轻量级进程。
- 一个没有线程的进程是可以被看作单线程的,如果一个进程内拥有多个线程, 进程的执行过程不是一条线(线程)的,而是多条线(线程)共同完成的。
- 系统在运行的时候会为每个进程分配不同的内存区域,但是不会为线程分配内存(线程所使 用的资源是它所属的进程的资源),线程组只能共享资源。那就是说,除了CPU之外(线程在运 行的时候要占用CPU资源),计算机内部的软硬件资源的分配与线程无关,线程只能共享它所属 进程的资源。
- 与进程的控制表PCB相似,线程也有自己的控制表TCB,但是TCB中所保存的线程状态比PCB表 中少很多
- 进程是系统所有资源分配时候的一个基本单位,拥有一个完整的虚拟空间地址,并不依赖线 程而独立存在。
更加详细的介绍,请参考这里。
1.2 线程的优点
由于以下原因,行业内广泛地在编程库和操作系统中实现线程:
- 减少内存占用量。创建另一个线程的内存消耗量被限制为线程所需要的堆栈加上线程管理器需 要的一些簿记内存。
- 不需要采用先进的技术来访问服务器全局数据。如果数据有可能由另一个同时运行的线程修改, 则要做的一切就是使用互斥体 保护相关段。
- 创建线程所需要的时间大大小于创建进程所需要的时间,原因是不必复制堆部分(它可能很大)。
- 在线程之间进行环境切换时,内核在调度器中花的时间比在过程之间进行切换花的时间少。 这给负担很重的服务器留下了更多的cpu时间处理工作。
1.3 线程的缺点
尽管线程在现代计算机中极具重要性,它们却有很多缺点:
- 编程错误代价惨重。如果一个线程崩溃,会使得整个服务器停止。一个坏线程可能会毁坏全局 数据,导致其他线程无法工作
- 容易产生编程错误。程序员必须不断考虑其他一些线程可能正在做引起麻烦的事情,以及如何 避免这种麻烦。需要采用额外的防范方法编制程序。
- 线程服务器在同步漏洞方面声名狼藉,这些漏洞几乎无法在测试中进行复制,却在生产期间很 不合时宜地出现。这类漏洞发生几率之所以如此高,是由于使用共享地址空间,这会产生更高程 度的线程交互。
- 有时候互斥体争用难以控制。如果在同一时间有太多的线程想得到相同的互斥体,就会导致过 度的环境切换,大量的CPU时间就会花在内核调度器上,因而几乎没有时间执行工作。
- 32位系统限制为每个线程使用4G地址空间。由于所有的线程都共享相同的地址空间,理论上整 个服务器就被限制为使用4G RAM,即便实际上有更多的物理RAM也不例外。实际使用时,地址空间 会在一个小得多的限值下开始变得非常拥挤。
- 拥挤的32位地址空间会带来另一个问题。每个线程都需要一些堆栈空间,在分配了堆栈后, 即便不使用所分配的大部分空间,服务器也会为其保留地址空间。每个新堆栈都会减少用于堆的 潜在空间。因此,即使有足够的物理内存,也不可能同时使用大型缓冲区,同时使用大量并发线 程,以及同时为每个线程提供足够的堆栈空间。
1.4 进程的优点
线程的缺点与使用多进程的优点相对应:
- 编程错误并不致命。尽管有可能发生,但一个坏分支服务器进程并不容易中断整个服务器。
- 编程错误发生的可能性小得多。在大多数时候,程序员只需要考虑一个线程的执行,而不用受 可能的并发侵入者的打扰。
- 飘忽不定的漏洞少得多。如果漏洞出现一次,通常非常容易复制。由于各个分支进程有自己的 地址空间,它们之间并没有太多的交互。
- 在32位系统中,地址空间用完的问题并不严重。
1.5 进程的缺点
- 内存利用不够好。当子进程发生分支时,会不必要地复制大型内存程序段。
- 需要采用特殊技术实现进程数据共享。(IPC)
- 创建进程比创建线程需要更多的内核系统开销。对性能的一个很大的打击是需要 复制父进程的 数据段。不过,Linux 在这方面的手段是执行所谓的copy-on-write 。除非子进程或父进程修改了 父进程页,否则并不真正复制父进程页。在此之前,父子进程使用相同的页。
- 进程之间的环境切换比线程之间的环境切换更为耗时,因为内核需要切换页,文件描述符表 及其他额外的内容信息。留给服务器执行实际工作的时间减少。
进程通信
进程通信的应用场景
数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几兆字节之间。
共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
资源共享:多个进程之间共享同样的资源。为了作到这一点,需要内核提供锁和同步机制。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程通信的方式
管道( pipe ):
管道包括三种:
- 普通管道PIPE: 通常有两种限制,一是单工,只能单向传输;二是只能在父子或者兄弟进程间使用.
- 流管道s_pipe: 去除了第一种限制,为半双工,只能在父子或兄弟进程间使用,可以双向传输.
- 命名管道:name_pipe:去除了第二种限制,可以在许多并不相关的进程之间进行通讯.
信号量( semophore ) :
- 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
消息队列( message queue ) :
- 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
信号 ( sinal ) :
- 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
共享内存( shared memory ) :
- 共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
套接字( socket ) :
- 套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。
内存管理
在现代的操作系统中,同一时间运行多个进程是再正常不过的了。为了解决直接操作内存带来的各种问题,引入的地址空间(Address Space),这允许每个进程拥有自己的地址。这还需要硬件上存在两个寄存器,基址寄存器(base register)和界址寄存器(limit register),第一个寄存器保存进程的开始地址,第二个寄存器保存上界,防止内存溢出。
虚拟内存(Virtual Memory)
虚拟内存是现代操作系统普遍使用的一种技术。前面所讲的抽象满足了多进程的要求,但很多情况下,现有内存无法满足仅仅一个大进程的内存要求(比如很多游戏,都是10G+的级别)。在早期的操作系统曾使用覆盖(overlays)来解决这个问题,将一个程序分为多个块,基本思想是先将块0加入内存,块0执行完后,将块1加入内存。依次往复,这个解决方案最大的问题是需要程序员去程序进行分块,这是一个费时费力让人痛苦不堪的过程。后来这个解决方案的修正版就是虚拟内存。
虚拟内存的基本思想是,每个进程有用独立的逻辑地址空间,内存被分为大小相等的多个块,称为页(Page).每个页都是一段连续的地址。对于进程来看,逻辑上貌似有很多内存空间,其中一部分对应物理内存上的一块(称为页框,通常页和页框大小相等),还有一些没加载在内存中的对应在硬盘上,如图5所示。
由图5可以看出,虚拟内存实际上可以比物理内存大。当访问虚拟内存时,会访问MMU(内存管理单元)去匹配对应的物理地址(比如图5的0,1,2),而如果虚拟内存的页并不存在于物理内存中(如图5的3,4),会产生缺页中断,从磁盘中取得缺的页放入内存,如果内存已满,还会根据某种算法将磁盘中的页换出。
因为在计算机系统中,读取少量数据硬盘通常需要几毫秒,而内存中仅仅需要几纳秒。一条CPU指令也通常是几纳秒,如果在执行CPU指令时,产生几次缺页中断,那性能可想而知,因此尽量减少从硬盘的读取无疑是大大的提升了性能。而前面知道,物理内存是极其有限的,当虚拟内存所求的页不在物理内存中时,将需要将物理内存中的页替换出去,选择哪些页替换出去就显得尤为重要,如果算法不好将未来需要使用的页替换出去,则以后使用时还需要替换进来,这无疑是降低效率的,让我们来看几种页面替换算法。
最佳置换算法(Optimal Page Replacement Algorithm)
最佳置换算法是将未来最久不使用的页替换出去,这听起来很简单,但是无法实现。但是这种算法可以作为衡量其它算法的基准。
最近不常使用算法(Not Recently Used Replacement Algorithm)
这种算法给每个页一个标志位,R表示最近被访问过,M表示被修改过。定期对R进行清零。这个算法的思路是首先淘汰那些未被访问过R=0的页,其次是被访问过R=1,未被修改过M=0的页,最后是R=1,M=1的页。
先进先出页面置换算法(First-In,First-Out Page Replacement Algorithm)
这种算法的思想是淘汰在内存中最久的页,这种算法的性能接近于随机淘汰。并不好。
改进型FIFO算法(Second Chance Page Replacement Algorithm)
这种算法是在FIFO的基础上,为了避免置换出经常使用的页,增加一个标志位R,如果最近使用过将R置1,当页将会淘汰时,如果R为1,则不淘汰页,将R置0.而那些R=0的页将被淘汰时,直接淘汰。这种算法避免了经常被使用的页被淘汰。
时钟替换算法(Clock Page Replacement Algorithm)
虽然改进型FIFO算法避免置换出常用的页,但由于需要经常移动页,效率并不高。因此在改进型FIFO算法的基础上,将队列首位相连形成一个环路,当缺页中断产生时,从当前位置开始找R=0的页,而所经过的R=1的页被置0,并不需要移动页。如图6所示。
图6.时钟置换算法
最久未使用算法(LRU Page Replacement Algorithm)
LRU算法的思路是淘汰最近最长未使用的页。这种算法性能比较好,但实现起来比较困难。
用户态与内核态
用户空间就是用户进程所在的内存区域,相对的,系统空间就是操作系统占据的内存区域。用户进程和系统进程的所有数据都在内存中。
是谁来划分内存空间的呢?在电脑开机之前,内存就是一块原始的物理内存。什么也没有。开机加电,系统启动后,就对物理内存进行了划分。当然,这是系统的规定,物理内存条上并没有划分好的地址和空间范围。这些划分都是操作系统在逻辑上的划分。不同版本的操作系统划分的结果都是不一样的。
为什么要划分用户空间和系统空间呢?当然是有必要的。操作系统的数据都是存放于系统空间的,用户进程的数据是存放于用户空间的。这是第一点,不同的身份,数据放置的位置必然不一样,否则大混战就会导致系统的数据和用户的数据混在一起,系统就不能很好的运行了。分开来存放,就让系统的数据和用户的数据互不干扰,保证系统的稳定性。分开存放,管理上很方便,而更重要的是,将用户的数据和系统的数据隔离开,就可以对两部分的数据的访问进行控制。这样就可以确保用户程序不能随便操作系统的数据,这样防止用户程序误操作或者是恶意破坏系统。
处于用户态的程序只能访问用户空间,而处于内核态的程序可以访问用户空间和内核空间。那么用户态和内核态有什么区别呢?
当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。
内核态与用户态是操作系统的两种运行级别,Intel x86架构提供Ring0-Ring3四种级别的运行模式,Ring0级别最高,Ring3最低。Linux使用了Ring3级别运行用户态,Ring0作为 内核态,没有使用Ring1和Ring2。Ring3状态不能访问Ring0的地址空间,包括代码和数据。程序特权级别的不同,其所拥有的权力也不同。
用户态切换到内核态的3种方式
a. 系统调用
这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如fork()实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。
b. 异常
当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
c. 外围设备的中断
当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。
用户态和内核态的切换
因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。所以,为了减少有限资源的访问和使用冲突,Unix/Linux的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。简单说就是有多大能力做多大的事,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。Intel的X86架构的CPU提供了0到3四个特权级,数字越小,特权越高,Linux操作系统中主要采用了0和3两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这就涉及到一个从用户态切换到内核态的过程。比如C函数库中的内存分配函数malloc(),它具体是使用sbrk()系统调用来分配内存,当malloc调用sbrk()的时候就涉及一次从用户态到内核态的切换,类似的函数还有printf(),调用的是wirte()系统调用来输出字符串,等等。
操作系统锁与互斥与死锁
线程同步的各种方法。包括:
读写锁
读写锁适合于对数据结构的读次数比写次数多得多的情况.因为,读模式锁定时可以共享,以写 模式锁住时意味着独占,所以读写锁又叫共享-独占锁.
条件变量
条件变量参考这里。
与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直 到某特殊情况发生为止。通常条件变量和互斥锁同时使用。
信号量
信号量(semaphore)简单好理解,不知道它有什么缺点,在《unix 环境高级编程》中居然没有讲。我们大学课堂,上操作系统,各种PV操作,其实就是在操作信号量。
文件互斥
文件互斥并不是操作系统提供的一组API,而是使用了一点小技巧,我们可以通过linux下文件互 斥地打开,实现线程/进程互斥的访问资源,以此实现多线程编程。
互斥锁与信号量的区别
“信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作(大家都在semtake的时候,就阻塞在 哪里)。而互斥锁是用在多线程多任务互斥的,一个线程占用了某一个资源,那么别的线程就无法访问,直到这个线程unlock,其他的线程才开始可以利用这 个资源。比如对全局变量的访问,有时要加锁,操作完了,在解锁。有的时候锁和信号量会同时使用的”
也就是说,信号量不一定是锁定某一个资源,而是流程上的概念,比如:有A,B两个线程,B线程要等A线程完成某一任务以后再进行自己下面的步骤,这个任务 并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。而线程互斥量则是“锁住某一资源”的概念,在锁定期间内,其他线程无法对被保护的数据进 行操作。在有些情况下两者可以互换。
两者之间的区别:
作用域
信号量: 进程间或线程间(linux仅线程间的无名信号量pthread semaphore)
互斥锁: 线程间
- 互斥量用于线程的互斥,信号量用于线程的同步。
这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源
- 互斥量值只能为0/1,信号量值可以为非负整数。
也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问。
- 互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。