- A+
为什么要引入进程?
我们知道,最早出现的OS是单道批处理系统,由于它是顺序执行程序的,即一个一个地按先到先执行的顺序依次执行。因此,CPU的高速性与I/O的低速性之间的矛盾很明显(IO低速是指这个程序在运行过程中需要频繁的访问IO资源,如需要输入数据,如读取硬盘文件,使用打印机,在这个程序进行IO操作的时候,一般来说不占用CPU资源,此时把CPU资源分配给其他的进程,这样就提高了CPU的利用率)。为了缓解这个矛盾,人们引入了多道批处理系统,该系统让程序并发执行,即在一个程序发起I/O请求时CPU不再选择等待I/O完成,而是转去执行下一个程序。
并发执行特点:
- 间断性:多个程序宏观上并发,微观上轮流使用CPU,所以每一个程序都是走走停停,具有间断性,这样做提高了资源的利用率
- 失去封闭性:多个程序共享系统的资源,所以资源的状态不专属于一个程序,失去了封闭性
- 不可再现性:由于多个程序共享资源,每个程序都有对资源修改的权利,所以程序的输出结果是不一定的
操作系统设计的最终目的是不但要提高资源的利用率,而且还要具备封闭性和可再现性,如何解决封闭性的问题?
解决封闭性有两种方法
1.Bernstein条件:既然所有运行的程序不能并发运行,那么能否让一部分互不干扰的程序并发运行,在一部分程序上实现并发
具体内容:
其实要表达的意思是这样的,将程序的所有读的集合和写的集合挪列出来,如果两个程序仅仅只有读集有交集,说明两个程序是可以并发的,也就是说两个程序可以同时读,但一个读一个写和同时写都是不能并发的
2.引入进程
那么能否有一个东西将资源封闭起来呢?
进程就被引入了,进程是系统进行资源分配和处理机调度的独立单位,所以进程之间是可以并发的。(意思就是以前每个程序都共享所有的资源,现在就是让每个程序都只能拥有一部分资源,这就解决了封闭性,解决了封闭性也就实现了再现性)
进程 = 数据段 + 程序段 + PCB
为什么要引入线程?
进程是一个可拥有资源的独立单位,进程同时又是一个可独立调度和分派的基本单位。正是由于进程有这两个基本属性,才使进程成为一个能独立运行的基本单位,从而也就构成了进程并发执行的基础。
为使程序能并发执行,系统必须对进程进行以下的一系列操作:创建进程、撤销进程以及进程间切换。据此可知,由于进程是一个资源的拥有者,因而在创建、撤消和切换中,系统必须为之付出较大的时空开销。这就限制了系统中所设置进程的数目,而且进程切换也不宜过于频繁,从而限制了并发程度的进一步提高。
要设法将进程的上述两个属性分开,由OS分开处理,亦即并不把作为调度和分派的基本单位也同时作为拥有资源的单位,以做到“轻装上阵”,而对于拥有资源的基本单位,又不对之施以频繁的切换。正是在这种思想的指导下,形成了线程的概念。
自己理解:
- 尽可能使资源分配和CPU调度分开,因为,因为进程间切换需要保存进程CPU环境(栈、寄存器、页表和文件句柄等)以及新调度的进程CPU环境的设置,而线程切换保存和设置程序计数器、少量寄存器和栈的内容
- 应用程序功能越来越多,可以用多线程来实现(比如QQ可以用一个线程来接受文件,一个线程来进行语音通话,其实也可以通过创建新的进程来实现,第一点才是引入的根本目的)
进程、线程和协程的区别和联系
一个进程可以创建多少个线程?
进程的虚拟内存空间大小为4G,其中 3G-4G为内核空间,用户可用的的空间为3G,具体可以创建多少个要看线程的大小,Linux下一个线程大约为10M,可以创建300个线程左右。
虚拟地址
对于每一个进程都会对应一个虚拟地址空间,对于32位的操作系统(其指令的位数最大为32位,因此地址码最多32位),虚拟地址空间的大小为B即0~4GB的虚拟地址空间,其中内核空间为1GB,如下所示:
每一个进程的进程控制块PCB都位于内核区,在每一个进程的PCB中有一个文件描述符表(是一个数组),用于标记该进程所打开的所有文件。从文件描述符表可以看出每一个进程最多能打开1024个文件,其中有三个文件默认是一直处于打开状态的(即进程创建完成时就处于打开状态),分别是:标准输入 STDIN_FILENO,其文件描述符为0;标准输出 STDOUT_FILENO,其文件描述符为1;错误输出 STDERR_FILENO,其文件描述符为2,其中文件描述符0和1可以省略不写。供我们用户打开的文件,只能够占据从3开始的位置(即其文件描述符为3以后的数字,3~1023)。每打开一个文件就会占用一个文件描述符,且使用的是空闲的最小的一个文件描述符。
Linux下可执行文件的格式为ELF:[root@localhost Calc]# file zsx
zsx: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=0x14ef2d34126e7c54141b73c31968bd825ca522ba, not stripped //可以看出zsx为64位(即机器指令位数为64位,OS位数)的可执行文件,其格式为ELF。
对于每一个程序在执行时(如上图中的a.out),此时会产生一个相应的进程,系统都会自动为其分配一个04G的虚拟地址空间,其中1G的内核空间用于:进程管理、内存管理、设备管理和虚拟文件系统等。下面详细介绍03G的用户空间。
强调一点:以下说明的各段都是与编程相关的,不包括虚拟地址空间的全部。
0~3G的用户空间。从小到大(从下往上)依次为:保留区(受保护的地址)、代码段、数据段(.data段)、.bss段、堆空间、内存映射段、栈空间、命令行参数和环境变量。下面依次对每一个段做简单的介绍:
1.保留区(受保护的地址)
保留区即为受保护的地址,大小为0~4K,位于虚拟地址空间的最低部分,未赋予物理地址(不会与内存地址相对应,因此其不会放任何内容)。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。大多数操作系统中,极小的地址通常都是不允许访问的,如NULL。C语言将无效指针赋值为0也是出于这种考虑,因为0地址上正常情况下不会存放有效的可访问数据。将指针赋值为0,意味着该指针将永远不会被使用,从而不会出现野指针情况。#define NULL 0 与 #define NULL (void)0 在C语言中是等效的,而在C++中,只能用#define NULL 0,后面 #define NULL (void)0的使用会出错。
2.代码段
代码段也称正文段或文本段,通常用于存放程序执行代码(即CPU执行的机器指令)。一般C语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。代码段通常属于只读,以防止其他程序意外地修改其指令(对该段的写操作将导致段错误)。某些架构也允许代码段为可写,即允许修改程序。
3.数据段(.data段)
数据段通常用于存放程序中已初始化的全局变量和静态局部变量。数据段属于静态内存分配(静态存储区),可读可写。由于全局变量未初始化时,其默认值为0,因此值为0的全局变量位于.bbs段(不位于数据段)。对于未初始化的局部变量,其值是不可预测的。注意:在代码段和数据段之间还包括其它段:只读数据段和符号段等。
4..bbs段
该段用于存放未初始化的全局变量和静态局部变量,包括值为0的全局变量。 数据段和.bbs段又称为全局数据区,前者初始化,后者未初始化。
ELF段包括:代码段、其它段(只读数据段和符号段等)、.data段(数据段)和.bbs段,都属于可执行程序部分。
5.堆空间
new( )和malloc( )函数分配的空间就属于对空间,用于内存空间的分配,其从下往上。 堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问。当进程调用malloc(C) 和new (C++)等函数分配内存时,新分配的内存动态添加到堆上(扩张);当调用free(C)/delete(C++)等函数释放内存时,被释放的内存从堆中剔除(缩减) 。
6.内存映射段(共享库)
此处,内核将硬盘文件的内容直接映射到内存, 任何应用程序都可通过Linux的mmap()系统调用请求这种映射。内存映射是一种方便高效的文件I/O方式, 因而被用于装载动态共享库。如C标准库函数(fread、fwrite、fopen等)和Linux系统I/O函数,它们都是动态库函数,其中C标准库函数都被封装在了/lib/libc.so库文件中,都是二进制文件。这些动态库函数都是与位置无关的代码,即每次被加载进入内存映射区时的位置都是不一样的,因此使用的是其本身的逻辑地址,经过变换成线性地址(虚拟地址),然后再映射到内存。而静态库不一样,由于静态库被链接到可执行文件中,因此其位于代码段,每次在地址空间中的位置都是固定的。
7.栈空间
用于存放局部变量(非静态局部变量,C语言称为自动变量),分配存储空间时从上往下。栈和堆都是后进先出的数据结构。
8.命令行参数
该段用于存放命令行参数的内容:argc和argv。
9.环境变量
用于存放当前的环境变量,在Linux中用env命令可以查看其值。
10.虚拟地址空间的作用(好处)
1.方面编译器和操作系统安排程序的地址;2.方便实现各个进程空间之间的隔离,互不干扰,因为每个进程都对应自己的虚拟地址空间;3.实现虚拟存储,从逻辑上扩大了内存。
补充内容:
代码段(.text段)与只读数据段和符号段(.rodata段),都属于只能读的部分,在链接的时候这两部分会链接成为一个整体;而.data段和.bbs段属于可读可写RW的部分。这四个部分都是以页(每页4KB)的形式存放在内存中。进程控制块PCB(又叫进程描述符)放于内核空间。
多个进程在并发执行时,这些进程的用户空间都是彼此独立的,因此各个进程的用户空间在映射为内存空间使都是独立的,互不干扰,这是MMU地址变换必须要能够保证的。例如,各个进程的.text段、只读数据段和符号段、.data段和.bbs段等在用户空间中使用到的其它数据信息,都会与页为基本单位放在内存中,各个进程的映射是独立的。而对于内核空间,由于只有一个操作系统,内核空间主要是 机器指令、操作系统内核的各个模块等,它们是公用的,因此每个进程的映射方式一样。强调一点:每个进程用到或即将用到的数据才会调入内存,其余都在磁盘上。但是各个进程内核空间的进程控制块(进程描述符)映射的地点是不一样的,也是相互独立的。共用的模块才是一样的。 这些都是MMU的实现机制所决定的。如果感兴趣,可以看看MMU的实现机制。
内存碎片
内存碎片是什么?关于内存碎片
内存碎片通常分为内部碎片和外部碎片。
所谓内部碎片指的就是,系统为某项功能分派了一定的内存,但是该功能的实现没有用完所有系统分配的。余下的部分就被成为内存碎片的内部碎片。
外部内存指的是有一些连续型内存太小了没办法被系统分配到某个功能所导致的浪费。
内部碎片如何产生?
个人理解:
在请求分页式内存申请中,请求的内存是按照页面划分的,那么申请的内存很大概率大于需要用到的内存,这样就产生了内部碎片
外部碎片如何产生?
比如某个内存地址被A进程使用,A进程结束运行之后,该内存就空闲出来,但是空闲出来的内存可能由于大小(或者太大),无法分配给其他进程使用,就产生外部碎片。
进程通信
进程之间的通信方式以及优缺点
- 管道(PIPE)
- 有名管道:一种半双工的通信方式,它允许无亲缘关系进程间的通信
- 优点:可以实现任意关系的进程间的通信
- 缺点:
- 长期存于系统中,使用不当容易出错
- 缓冲区有限
- 无名管道:一种半双工的通信方式,只能在具有亲缘关系的进程间使用(父子进程)
- 优点:简单方便
- 缺点:
- 局限于单向通信
- 只能创建在它的进程以及其有亲缘关系的进程之间
- 缓冲区有限
- 有名管道:一种半双工的通信方式,它允许无亲缘关系进程间的通信
- 信号量(Semaphore):一个计数器,可以用来控制多个线程对共享资源的访问
- 优点:可以同步进程
- 缺点:信号量有限
- 信号(Signal):一种比较复杂的通信方式,用于通知接收进程某个事件已经发生
- 消息队列(Message Queue):是消息的链表,存放在内核中并由消息队列标识符标识
- 优点:可以实现任意进程间的通信,并通过系统调用函数来实现消息发送和接收之间的同步,无需考虑同步问题,方便
- 缺点:信息的复制需要额外消耗 CPU 的时间,不适宜于信息量大或操作频繁的场合
- 共享内存(Shared Memory):映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问
- 优点:无须复制,快捷,信息量大
- 缺点:
- 通信是通过将共享空间缓冲区直接附加到进程的虚拟地址空间中来实现的,因此进程间的读写操作的同步问题
- 利用内存缓冲区直接交换信息,内存的实体存在于计算机中,只能同一个计算机系统中的诸多进程共享,不方便网络通信
- 套接字(Socket):可用于不同计算机间的进程通信
- 优点:
- 传输数据为字节级,传输数据可自定义,数据量小效率高
- 传输数据时间短,性能高
- 适合于客户端和服务器端之间信息实时交互
- 可以加密,数据安全性强
- 缺点:需对传输的数据进行解析,转化成应用级的数据。
- 优点:
线程之间的通信方式
-
锁机制:包括互斥锁/量(mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(condition)
-
互斥锁/量(mutex):提供了以排他方式防止数据结构被并发修改的方法。
-
读写锁(reader-writer lock):允许多个线程同时读共享数据,而对写操作是互斥的。
-
自旋锁(spin lock)与互斥锁类似,都是为了保护共享资源。互斥锁是当资源被占用,申请者进入睡眠状态;而自旋锁则循环检测保持者是否已经释放锁。
-
条件变量(condition):可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
-
信号量机制(Semaphore)
- 无名线程信号量
- 命名线程信号量
-
信号机制(Signal):类似进程间的信号处理
-
屏障(barrier):屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制
什么是阻塞与非阻塞
- 阻塞模式就是如果调用某个函数(如read函数进行文件读取),没有相应的结果就一直等待,直到结果出现(直到读缓冲区有内容)
- 非阻塞模式就是调用某个函数(如read函数读取缓冲区内容),如果缓冲区为空,就立即返回,不会等待
epoll的水平模式的边缘模式
- 水平模式(LT),只要文件描述符关联的读缓冲区(和写缓冲区)有数据可读(可写),就一直发出读(写)信号,支持阻塞和非阻塞
- 边缘模式(ET),文件描述符关联的读(写)缓冲区有数据到来(可写),就发出读(写)的信号,但是只发出一次,之后就不再发出,支持阻塞和非阻塞
线程同步,异步
-
同步,意思就是在发出功能调用的时候,必须等待这个结果,才能执行下一步的操作
-
异步,和同步相对,在发出功能调用的时候,不需要等待调用结果,可以继续往下执行,该功能的调用结果会通过状态,通知的回调告诉调用者
线程安全
-
多线程在并行执行并且访问共享数据的时候,如果每次都能得到确定的结果,那么就是线程安全的,即每次得到的结果和线程执行的顺序无关。
-
多线程对全局变量,静态变量,堆区数据进行操作的时候,一般来说的不安全的
-
产生线程不安全的原因,是因为线程对代码执行的过程中,并不是原子操作,有线程的切换
-
保证线程安全策略:用mutex(互斥锁),用原子操作
-
vector保证线程安全:1.用mutex(互斥锁) 2. 固定vector的大小