操作系统——进程和线程
进程五个状态:创建、就绪、阻塞、运行、结束(阻塞挂起状态、就绪挂起状态)
(1)就绪:进程已经获得了除了处理器之外的所有资源,一旦获得处理器,就可以立即执行。
(2)执行:当一个进程获得并要的资源并正在CPU上执行。
(3)阻塞:正在执行的进程,由于发生某事件而暂时无法执行下去,比如缺少资源。当进程处于阻塞状态,即使把处理器分配给该进程,它也无法运行。
在时间片轮转调度中,时间片用完以后,进程转化为就绪态。
PCB(保存进程切换时的相关信息,以便进程重新执行从断点处执行,是一个既能标识进程的存在、又能刻画执行瞬间特征的数据结构)、程序段、数据段。
PCB是进程存在的唯一标识,包括进程PID、进程当前状态、进程队列指针、程序和数据地址、进程优先级、CPU现场保护区、通信信息、家族联系、占有资源清单。PCB可以保证程序的并发执行。
进程和线程的区别:
(1)常见的说法。进程是资源分配的最小单位,线程是程序执行、资源调度的最小单位。
(2)从资源角度。进程拥有一个完整的资源平台,线程只独享必不可少的资源,如寄存器和栈。
(3)从状态角度。线程同样有就绪、阻塞、运行三种基本状态,同样有状态转换。
(4)从时间和空间开销角度。线程能减少开销。线程创建时不涉及资源管理信息,而是共享他们。释放时释放的资源比较少。切换时不切换页表。通信时共享资源,无需经过内核耗时。
进程切换和线程切换。
虚拟内存是逻辑地址,是为了扩大物理内存地址的使用范围(内存里的页表和快表记录了逻辑地址的页号到物理地址的内存块号,结合页内偏移量,缺页中断机制)。
一个进程拥有一个独立的虚拟内存,虚拟内存是一个页,有页号,所以进程切换涉及到的是页的切换。而线程是共享所在进程的虚拟地址空间的,就是共享一个页,所以线程切换时不会涉及到页的切换。这个页是虚拟内存层面的概念,具体落实到物理内存上,由于组里内存是页表查页号对应的内存块号,再加上页内偏移量来确认。
进程切换一般是由中断/系统调用产生。从用户态进入内核态,操作系统内核就会负责保存进程A在CPU中的上下文(程序计数器、寄存器)到PCB_A。从PCB_B取出进程B的CPU上下文,将CPU控制权转移给进程B,开始执行B的指令。
(2)协程可以节约资源,线程的栈有8M堆有64M;协程栈的大小通常只有KB,Go语言只有2-4KB,非常轻巧,可以轻松有十几万协程。
(3)协程是用户态的线程,用户可以自行控制协程的创建和销毁,极大程度避免了系统级线程上下文切换造成的资源浪费。协程执行效率高,是单个线程执行,以子程序中断的形式切换。协程不需要多线程的锁机制,不存在同时写变量冲突。
(4)协程的稳定性较高,因为线程之间通过内存共享数据,导致如果一个线程出错,进程中的所有线程会跟着一起崩溃。
(5)在协程的开发程序中,可以方便地将一些耗时的IO操作异步化,比如写文件、耗时IO请求。
子进程fork。该函数的调用每次都会返回两次,父进程中返回的是子进程的PID,子进程返回的是0。
fork调用失败的时候返回-1,并设置errno。fork函数复制当前进程,在内核进程表里面创建一个新的进程表项。
新的进程表项里面大多数属性都和原来的相同,比如堆指针、栈指针、标志寄存器数值。
也有一些属性被赋予新的数值,比如子进程的PPID被设置成原进程的PID,信号位图被清除(原本的信号处理函数没了)。
子进程的代码和父进程完全相同,数据会复制(比如堆数据、栈数据、静态数据)。
复制采用的是写时复制,只有那个进程对数据执行写操作发生缺页中断的时候,操作系统才会给子进程分配内存并复制父进程的数据。
创建父进程之后,父进程中打开的文件描述符默认在子进程也打开,而且fd的引用计数加1。
父进程的用户根目录、当前工作目录等变量的引用计数加1。
用户区父子进程读时共享,写时复制。内核区只共享文件描述符和mmap映射区。
共享:堆、栈、代码段、数据段、环境变量、全局变量、宿主目录位置、进程工作目录位置、信号处理方式等。
不共享:进程ID、返回值、各自父进程、进程创建的时间、闹钟、未决信号量。
如果需要在子进程执行其他程序,需要使用exec系列函数替换当前进程镜像。exec函数不会关闭原本进程打开的文件描述符,除非该fd设置了SOCK_CLOEXEC。
子进程vfork。这个函数也是创建一个进程,但是新进程的目的是exec一个新程序(如shell)。vfork的时候父子进程共享数据段但是不共享堆栈段,不像fork一样是子进程拷贝父进程的数据段、堆栈段。
fork和vfork的区别:
(1)fork的父子进程的执行次序不确定。vfork是保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。
(2)fork的子进程拷贝父进程的地址空间,子进程是父进程的一个复制品。vfork是子进程共享父进程的地址空间(准确来说,在调用exec或exit之前和父进程的数据都是共享的)。
子进程exit()结束自己的生命。它并没有真正被销毁,内核只是释放了该进程的所有资源(打开的文件、占用的内存等),但是还留着一个被称为僵尸进程的数据结构(包含进程号the process ID、退出状态、运行时间),直到这些信息被父进程通过wait/waitpid()取得才释放。僵尸进程不占有任何的内存空间,但是如果父进程不调用wait/waitpid进行回收,那么保留的信息就不会被释放,进程号会一直被占着。系统能使用的进程号是有限的,如果产生大量的僵尸进程,会因为没有可用的进程号,导致系统不能生成新的进程,这就是僵尸进程的危害。
孤儿进程,父进程先退出,子进程还没退出,那么子进程的父进程就变成init进程。那些子进程就是孤儿进程。孤儿进程没什么危害。
僵尸进程,父进程还在,子进程退出了,但是父进程没获取到它的退出状态,那么那个子进程就变成僵尸。sigchld信号通知内核对子进程的结束不关心,wait/waitpid。前面父进程阻塞,后面不阻塞立刻返回错误。
解决僵尸进程。
(1)父进程通过wait和waitpid等函数等待子进程的结束,但是这会导致父进程挂起。父进程如果不能和子进程并发执行,那么创建子进程的意义就没有。一个wait只能解决一个子进程,如果有多个子进程就要用多个wait。
(2)子进程退出的时候,向父进程发送一个SIGCHILD信号,父进程处理这个信号,并在信号处理函数调用wait处理僵尸进程。
(3)父进程通过一次fork产生一个子进程,然后立即执行wait(nullptr)等待子进程结束,然后子进程fork产生孙子进程,然后立即exit。这样子进程就会顺利终止,然后父进程继续执行。这个时候孙子进程已经失去它的父进程,将被转交给init进程托管。于是父进程和孙子进程就没有继承关系,他们的进程都是init。init进程在其子进程结束的时候会自动收尸。这样就不会产生僵尸进程。
(4)通过kill发送SIGTERM和SIGKILL信号,直接把父进程kill。
进程切换有非抢占式调度和抢占式调度。
进程通信方式如下。
(1)管道。匿名int pipe(int fd[2])是特殊的文件,只存在内存,不在文件系统中,只能在相关进程如父子间通信,只有单向通信,双向就要两个管道。父进程fork子进程才有两个f[0]和两个f[1]。
从管道一端写入的数据,实际是缓存在内核的,另一端读取,也就是从内核中读取。管道传输无格式的流而且大小受限。
有名mkfifo,可以在不相关通信。有名管道提前创建了一个类型为管道的设备文件,进程间使用设备文件就可以进行通信。
两种管道,写入的数据都存在于内核,读取需要从用户态切到内核态。
只有当管道里的内容被读完,命令才能正常退出,写入一条,读出一条。效率低,不适合进程间数据频繁交换。
(2)消息队列:保存在内核中的消息链表,通信过程中考虑用户态和内核态之间的数据拷贝耗时。
消息队列生命周期随内核,如果没有释放或关闭操作系统,就会一直存在。前面的匿名管道,随进程的创建而建立,随进程的结束而销毁。
通信不及时,附件有大小限制,不适合比较大的数据传输。
(3)共享内存:虚拟内存的一块,不用拷贝,但是带来数据混乱问题。
(4)信号量:解决数据混乱问题,整型计数器表示资源个数(PV操作),实现互斥1和同步0,不用于缓存通信之间的数据。
(5)信号:异常工作通知,SIG。
(6)socket:TCP字节流SOCK_STREAM,UDP数据包SOCK_DGRAM,本地socket。
单核CPU是系统将时间分割成很多时间段,交由不同的线程执行,所以实际单核CPU同一时刻只存在一个线程。多核CPU是多个单核CPU,可以同时执行多个线程。
程序的并发执行是指若干个程序段同时在系统中运行,这些程序的执行在时间上是重叠的,即一个程序的执行尚未结束,另一个程序的执行已经开始。
线程同步问题如下。
互斥用锁或信号量,同步用信号量。
生产者消费者问题。需要互斥:任何时候只能有一个线程操作缓冲区,说明缓冲区时临界代码。需要同步:缓冲区为空的时候,消费者必须等待生产者生产数据;缓冲区满的时候,生产者必须等待消费者取出数据。使用互斥信号量实现。
哲学家就餐问题。
方案一:使用信号量PV,有可能五位哲学家同时拿起叉子,死锁。
方案二:一个哲学家进入临界区准确拿起筷子,另一些不能动,可以,但是不能两人同时进行。
方案三:采用奇偶分支结构,偶数编号的哲学家先拿左边的叉子再拿右边的,奇数编号的哲学家先拿右边的叉子再拿左边的。
读者写者问题。读优先、写优先、公平读写策略。
https://blog.csdn.net/qq_44096670/article/details/119857779
死锁条件:互斥条件、占有并等待条件、不可剥夺条件、循环并等待条件(避免:资源有序分配)。
产生死锁的场景:比如一个线程对变量a加锁之后,尝试对变量b加锁。另一个线程对变量b加了锁,然后试图对a加锁,这时两个线程都不释放锁,加锁不会成功,造成两个线程处于死锁的状态。可以使用比如pthread_mutex_timeout函数来允许线程阻塞特定的时间,如果加锁失败就会返回超时。
死锁预防。
(1)破坏互斥条件。只有对必须互斥使用的资源进行争夺的时候才会导致死锁。如果把只能互斥使用的资源变成允许共享使用,则系统不会进入死锁状态。比如,SPOOLing计数把独占设备(打印机)在逻辑上改造成共享设备。
缺点:并不是所有资源都可以改造成可共享使用的资源。
为了系统安全,很多地方还必须保护这种互斥性。因此很多时候都无法破坏互斥条件。
(2)破坏不可剥夺条件。进程A获得的资源在没有使用完之前,不能被其他进程强行夺走,只能主动释放。第一种方法,当某个进程请求新的资源得不到满足的时候,必须立即释放保持的所有资源,等到以后需要的时候再重新申请。也就是说,就算有些资源还没使用完,也需要主动释放,从而破坏不可剥夺条件。第二种方法,当某个进程需要的资源被其他进程所占有,由操作系统协助,把想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级。
缺点:实现起来比较复杂;释放已经获得的资源可能造成前一阶段工作失效;反复申请和释放资源会增加系统开销,降低系统吞吐量。
(3)破坏请求和保持条件。进程已经保持拥有一个资源,但是又提出了新的资源请求,而该资源被其他进程占有,此时请求进程被阻塞,但是又对自己持有的资源保持不放。可以采用静态分配的方法,进程在运行前,一次申请完它需要的全部资源,资源没满足之前不让它投入运行。一旦投入运行,这些资源一直归他所有,该进程不会再请求别的任何资源。
缺点:有些资源可能只需要很短的时间,这样会造成资源利用率降低,也可能导致某些进程饥饿。
(4)破坏循环等待条件。存在一个进程资源的循环等待链,比如红绿灯路,链中的每一个进程已经获得的资源都会同时被下一个进程请求。可以采用顺序资源分配法,首先给系统资源编号,然后规定每个进程必须按照编号递增的顺序请求资源,编号相同的资源一次申请完。一个进程只有已占有小编号的资源时,才有资格申请更大编号的资源。按照这个规则,已经持有大编号资源的进程不可能逆向申请小编号的资源,从而就不会产生循环等待的现象。
缺点:不方便增加新的设备,因为可能需要重新分配所有编号。进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费。必须按照规定次序申请资源,用户编程麻烦。
死锁避免。
安全序列就是指如果系统按照某种序列分配资源,则每个进程都能顺利完成。只要能找到一个安全序列,系统就是安全状态。当然,安全序列可能有很多个。如果分配了资源之后,系统中找不到任何一个安全序列,系统就进入不安全状态。这就意味着所有进程无法顺序执行。
如果系统处于安全状态,就一定不会发生死锁,如果系统进入不安全状态,就可能发生死锁。因此可以在资源分配之前预先判断这次分配是否会导致系统进入不安全状态,以此来决定是否答应资源分配请求。这就是银行家算法的核心思想。
银行家算法:在进程提出资源申请的时候,先预判此次分配是否会导致系统进入不安全的状态。如果会进入不安全的状态,就暂时不答应这次请求,先让这个进程阻塞等待。
死锁检测感知。
为了能对系统是否发生死锁进行检测,必须用某种数据结构来保存资源的请求和分配信息,同时要提供一种算法并利用上述信息来检测系统是否已经进入死锁状态。
数据结构:进程节点存储一个进程,资源节点存储一类资源(一类资源可能有多个)。一种边是由进程节点指向资源节点,表示进程想要申请几个资源(每条边代表一个)。另一种边是由资源节点指向进程节点,表示已经为进程分配了几个资源。
资源使用过程:如果系统中剩余的可用资源数目满足进程的需求,那么这个进程暂时不会阻塞,可以顺利执行下去。如果这个进程执行结束了,就把资源归还给系统,就可能使得某些正在等待资源的进程被激活,并顺利执行下去。相应的,这些被激活的进程执行完了又归还一些资源,这样可能又会激活另外一些阻塞的进程。
如果按照上述过程,最终能消除所有边,就说这个图可以完全简化,此时一定可以找到一个安全序列,没有发生死锁。
如果最终不能消除所有边,此时就是发生了死锁,最终还连着边的那些进程就是处于死锁状态的进程。
解除死锁。
(1)资源剥夺法。把某些死锁进程挂起,并抢占它的资源。把这些资源分配给其他死锁进程,但是应该防止被挂起的进程长时间得不到资源而饥饿。(2)撤销进程法,也叫终止进程法。强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源。这种方式的优点是实现简单,但是符出的代价可能很大。(3)进程回退法。让一个或多个死锁进程回退到足以避免死锁的地步,这就要求系统要记录进程的历史信息。
互斥量pthread_mutex_t和读写锁pthread_rwlock_init的区别。读写锁和互斥量很像,但是它允许更高的并行性。互斥量只有锁住和没锁住的状态,一次只有一个线程可以加锁。读写锁可以有三种状态:读模式下的加锁状态、写模式下的加锁状态、不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是一次可以有多个线程占有读模式的读写锁。
读写锁非常适用于对数据读的次数远远大于写的情况。和互斥量相比,读写锁在使用之前必须初始化,在释放它们底层的内存之前必须销毁。
互斥锁、自旋锁:申请锁失败后的处理方式不同。互斥锁要上用户和内核之间下文切换,自旋锁适用于执行时间较短的。
读写锁:写锁是独占式的,类似互斥锁和自旋锁。读锁是共享式的。
上述都是悲观锁。悲观和乐观的区别在于多线程同时修改共享资源的概率不同。
乐观锁是无锁编程,多人在线文档编辑、SVN、git,通过版本号。
加锁时注意:加锁粒度小、执行速度快、加合适的锁。
如果一个函数可以被多个线程调用,而且不发生竞态条件,就说明这个函数是可重入函数。
互斥锁:用于同步线程对共享数据的访问。
信号量:wait加锁-- P,post解锁++ V。try_wait尝试对信号量加锁。进化版的互斥锁,记录当前可利用的资源数量,当资源数小于0的时候阻塞,大于0的时候开始操作。进入一个关键代码段之前,线程必须获取一个信号量。关键代码段完成之后,就释放信号量。
条件变量:用于在线程之间同步共享数据值。(生产者消费者模型的阻塞队列)
条件变量提供了一种线程间的通知机制,当某个共享数据达到某个值的时候,唤醒等待这个共享数据的线程。
当线程从等待已发出信号的条件变量中醒来,却发现它等待的条件不满足的时候,就会发生虚假唤醒。虚假是因为线程无缘无故被唤醒了。
在多处理器系统上,虚假唤醒的情况更加严重,因为多个线程在发出信号的时候等待条件变量,系统可能会决定将它们全部唤醒。通俗来讲就是,消费线程收到其他线程传来的唤醒信号,但是唤醒之后发现别的消费线程处理速度更快,此时已经没有数据可以被用于操作,这种情况的发生是预期之外的,称为虚假唤醒。
(1)当消费者数量只有1时,没有其他线程竞争队列,不会触发虚假唤醒。
(2)当生产者数量为1,如果使用notify_one通知消费线程,不会发生虚假唤醒,因为每次只有一个消费者线程收到信号被唤醒,在产品被消耗之前不会有新的信号发出来。
如果使用notify_all通知消费线程,会发生虚假唤醒,当一个线程被唤醒之前,可能有其他线程被唤醒先持有锁,消耗产品了。
(3)当生产者数量大于1,无论使用哪个都会发生虚假唤醒,多个生产者使用notify_one,多个线程被唤醒,都有可能其中一个处理快,将所有数据处理完。
方案:修改消费者线程,被唤醒之后再次判断,如果没有数据可以处理,应该继续休眠。
硬链接就是多个目录项的索引指针指向同一个inode。因此只有删除文件所有硬链接和所有源文件的时候,才算彻底删除文件。由于inode无法跨文件系统,每个文件系统都有各自的inode数据结构和列表,所以硬链接也不能跨文件系统。
多个文件名指向同一个inode号码,可以用不同的文件名访问同样的内容;修改文件内容会影响到所有的文件名;删除一个文件名不影响另一个。
软链接就是常见的快捷方式,就是在磁盘重新创建一个文件,文件的内容就是被链接文件的地址。所以这个软链接也是有自己的inode的。软链接可以跨文件系统,就算目标文件被删除了,也不会影响软链接文件的存在,只是软链接指向地址的文件找不到了。
软链接和硬连接的不同:文件A指向文件B的文件名,而不是文件B的inode号码,文件B的inode链接数目不会因此发生变化。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下
2021-02-25 PCL下采样