操作系统:进程篇

进程与线程:在内存中运行的程序就是进程,线程是进程的一条流程。由于进程是资源分配的基本单位,当进程阻塞,它的所有资源都不能使用。为了提高资源的利用率和并发度,引入了线程。一个进程就是由多个线程组成的,线程创建销毁开销很小。同一进程的线程共享同一个虚拟地址空间,所以一个进程内的线程通信也很方便。
协程: 协程是可以暂停和恢复的函数,可以认为它是轻量级线程。他可以提高线程内部的运行效率。比如说一个线程内部有好几个任务,我们可以把他们一个个协程,让他们轮流执行,当其中一个协程阻塞,我们可以让他暂停,切换到其他协程,阻塞结束后再恢复,相当于是在模拟并发,提高运行效率。且协程的切换也更轻量级。

进程状态:
在这里插入图片描述
刚创建出来是创建态,初始化完成可以运行了变为就绪态进入就绪队列,当被调度程序选中上cpu运行,进入运行态,当时间片用完回到就绪列/运行结束即结束态/阻塞被调度下cpu进入阻塞队列即阻塞态,被唤醒回到就绪队列。

进程间通信方式:进程都是独立的,想相互通信要通过内核。5种
管道:ls|grep mysql,这个|就是匿名管道,只能用于有亲缘关系的进程通信,因为…。有名管道也叫FIFO可以任意进程。管道缺点:单向,且缓冲区有限。
消息队列:流程:电子邮件,a进程发消息给b,把数据放到对应的消息队列就返回了,b需要时再读即可。消息队列本质是内核的消息链表。缺点:对消息队列的读写存在拷贝的开销。
共享内存:解决消息队列数据拷贝的问题:2个进程的虚拟地址映射同一块物理内存,直接通过物理内存交流,就不需要拷贝了。但要注意数据安全。
信号:进程收到信号后会处理(默认动作、自定义动作即信号捕捉、忽略)
Socket:实现不同主机的进程间通信。
相应的函数:

//无名管道,创建读端写端
int pipe(int pipefd[2]); 
//有名管道,创建了文件实体,后续就是通过这个文件来通信的
int mkfifo(const char* pathname, mode_t mode);
//共享内存
int shmget(key_t key, size_t size, int shmflg);//先创建共享内存
void* shm_ptr = shmat(shm_id, nullptr, 0);//附加到自己的进程地址里,后续即可通过它来通信

进程调度算法:也叫cpu调度算法:选择一个就绪进程上cpu。6大算法:
先来先服务:最简单,但对短作业不利。
最短作业优先:按运行时间长短排列运行,对长作业不利。
高响应比优先:按响应比排列=(等待时间+作业时间)/作业时间,大的先运行,权衡长短作业,相同作业时间等的长的先运行,相同等待时间作业时间短的先运行。
时间片轮转:设置一个时间片,每个进程运行一个时间片后下来。所有进程平等,不分优先级,可能是个缺点。
最高优先级:给进程设置优先级,抢占式:当就绪队列出现优先级更高的,直接强上cpu,非抢占:当前进程运行完再上。缺点:低优先级的可能永远不会执行。
多级反馈队列:多个队列,优先级从高到低,时间从低到高。进程按优先级放入相应的队列。永远运行高优先级队列里的进程,若时间片到了进程还没运行完,就移入下一级队列,下面的队列只有当上面的队列无进程才能被运行。他结合了前面的几种算法,按时间片优先运行高优先级进程,短作业可能很快就运行完了,长作业虽然要移入下面的队列,但运行时间也变长。
在这里插入图片描述
互斥:同一时间只能有一个进程或进程访问共享资源。常用互斥锁、信号量实现。
锁的底层原理:xxx
线程同步:多线程进行i++,步骤是这样的,先把i从内存拷贝到寄存器,在寄存器中++,然后拷贝回去。所以在a线程拷贝回去之前b线程也对其进行++,就是数据错误了。即
多线程访问共享资源会导致数据安全问题,所以需要一些机制来保证线程对共享资源的安全访问。
1、互斥锁:只有拿到锁的线程才能访问;
2、读写锁shared_lock:允许多线程同时读,单线程写。适合读多写少场景
3、条件变量:配合锁使用,调用cond_wait释放锁阻塞线程,收到通知唤醒线程获得锁;
4、信号量:分计数信号量和二进制信号量,二进制信号量类似于互斥锁,计数信号量标记了资源的数量,允许多线程同时访问。

典型的生产者消费者模型:用一个互斥锁,2个信号量(1个是生产者有多少空位可以生产,一个是消费者有多少资源可以消费)。
哲学家就餐问题:也是经典同步。5个人一桌,每2人之间放一个叉子,只有拿到左右2个叉子才能就餐。如何实现最大化就餐?
法1:信号量:p拿筷子直到拿到2个,但可能死锁。
法2:加入互斥锁,当有人准备拿筷子,其他人不准动,但4个人看1个人吃效率低。法3:不能用互斥锁,让偶数编号的人先拿左筷子后拿右筷子,奇数相反,就不会死锁,也可2人进餐。
读者写者问题:有写时,不准读和其他人写;可以多人读。数据库访问大概就是这样。这个问题的实现要分几种情况:读者优先,当读者进入临界区,阻塞写者,当最后一个读者离开,唤醒写着。写者优先:当有写者进入,就阻塞后续读者,前面的读者读完就开始写。公平机制:就按队列顺序执行,都不互相阻塞。(shared_lock)
死锁:2个线程分别锁了1个共享资源,现在都需要加锁对方资源,都等待对方解锁,形成死锁。
死锁要满足4个条件才会发生:访问资源是互斥的、线程持有资源并等待其他资源、已分配的资源不可被抢占、环路等待。故避免死锁只要破坏一个条件,如有序分配破坏环路等待如我们都先获取1锁再获取2锁、打破不可抢占:强行抢资源。打破持有并等待:必须一次性获取资源,不能持有一部分在这等。
锁的种类:最基本的互斥锁和自旋锁,都是独占锁。互斥锁:加锁失败,线程下cpu,阻塞,等锁被释放再唤醒,它有线程上下文切换的消耗,自旋锁会一直等待直到获得锁,会一直占用cpu,所以若你知道很快就能获得锁,就用自旋锁。读写锁:由互斥锁或自旋锁实现:写锁独占,读锁共享,所以在读多写少的情况下用它会比较好,也可以设置读优先锁,写优先锁,公平读写锁(获取锁的线程全排队)。前面3个锁都是悲观锁,他们认为并发访问共享资源很容易冲突,所以访问先要上锁。乐观锁:他认为冲突的概率很低,先修改共享资源,再验证这段时间内有没有冲突,若有放弃本次操作。如在线文档你总不能同时只能一个人编辑吧。
总结:互斥锁和自旋锁看着选,若要区分读写就用读写锁根据需要设置优先;者3个是悲观锁,若冲突概率低就要乐观锁。
多线程与多进程如何选择
对于cpu密集型任务,选多进程充分利用多核cpu的并行能力,若采用多线程,需要竞争cpu,造成性能瓶颈。
对于io密集型任务,经常需要等待外部资源,cpu大部分时间是空闲的,一般采用多线程,如在a线程等待io时让出cpu执行其他任务,提高cpu利用率。
2线程交替打印1-1000:

#include <iostream>
#include <thread>
#include <atomic>

int number = 1;
std::atomic<bool> flag(true); // 控制线程交替打印

void f1() {
    while (number <= 1000) {
        if (flag) {
            std::cout << "Thread 1: " << number++ << std::endl;
            flag = false;
        }
    }
}

void f2() {
    while (number <= 1000) {
        if (!flag) {
            std::cout << "Thread 2: " << number++ << std::endl;
            flag = true;
        }
    }
}

int main() {
    std::thread t1(f1);
    std::thread t2(f2);
    t1.join();
    t2.join();
}

IO阻塞、非阻塞、同步、异步
这里的阻塞非阻塞,同步异步都是针对网路来说的。
一次网络IO分2个阶段:数据准备(阻塞、非阻塞)和数据读写(同步、异步)
如recv函数,若是阻塞读取,没有数据:线程会进入阻塞状态;若是非阻塞,线程不会进入阻塞状态,通过返回值来判断(-1,0,>0,记得注意EAGAIN,EINTER等)。
当数据准备好了后(即内核缓冲区有数据了),来到第2阶段:数据读写(把内核缓冲区的数据搬到用户区的buf里,如read函数):谁搬的?若是应用程序搬的:这就是IO同步,消耗应用程序的时间;若是内核帮我们搬的,这就是异步IO,消耗内核的时间,内核搬好了通知应用程序(信号/回调),期间应用程序做自己的事。
只有使用了特殊的api才是异步io,其他的都是同步io。
注意:业务上的逻辑处理是同步还是异步(是否一定有序进行a->b),要区分开。

5种IO模型:同步阻塞IO(BIO)、同步非阻塞IO(NIO,一般是轮询检查)、信号驱动IO(分2个阶段:第一个数据准备阶段是异步的,内核把数据准备好后通过信号通知应用程序,然后进入第2个阶段数据读写,这是同步的需要程序自己搬运数据,与非阻塞io相比不需要应用进程不断的轮询,减少了系统api的调用次数)、IO复用,异步IO。

I/O多路复用: 前面学习了多进程多线程处理并发连接,一个连接对应一个进程或线程,对于高并发使用多进程多线程维护那么多进程或线程不现实。所以引入I/O多路复用:一个线程同时监听多个I/O也就是监听多个文件描述符,委托内核去检测哪些文件描述符发生变化(内核检测很快、位数组),进而把事件分发给子线程去处理,所以多路复用就是事件触发的机制。多路复用有3种实现:1.select采用1024大小的为数组存放文件描述符,调用select系统调用会把它拷贝到内核,内核遍历检测变化,再拷贝回用户区,用户再去遍历数组处理发生变化socket。所以他是2次拷贝,2次遍历。2.poll与select的区别就是他用动态数组,没有长度限制,但他俩开销都很大。3.epoll:调用epoll_create在内核创建epoll实例(结构体),里面有2个成员:红黑树、双链表。通过epoll_ctl把要检测的socket添加进红黑树进行检测,查找效率logn,很快,把发生变化的放入就绪链表,拷贝回用户区。epoll不需要每次检测都拷贝整个socket集合到内核区,且红黑树遍历更快,且直接拷贝发生改变的集合到用户区,用户不需要再遍历判断。
但并不是说epoll一定闭select/poll强,当连接数量少,且短连接较多,建议用select/poll,因为epoll每次添加一个文件描述符都要进行一次系统调用epoll_ctl,若短期连接较多触发频繁的系统调用,epoll性能可能会慢于他们;当监听的文件描述符较多,且长连接多,用epoll。

int epoll_create(1); //创建epoll实例,参数只要不是0就行

//向红黑树里add,del,mod文件描述符,mod:如监听读事件变为写事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

//检测红黑树里的这些事件,  timeout: -1表示一直等待,直到有事件发生。>=0表示等待指定时间
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll工作模式:LT模式(水平触发,默认模式)。当有可读事件,它会不断的通知你,直到你读完所有数据(如每次只能读5个字节)。ET模式(边沿触发):当有可读事件时,只会通知你一次,后续不通知了,除非又有新事件发生。
以快递为例:
水平触发+阻塞:驿站会一直给你短信,直到你把快递取掉,驿站才能干其他事。
边缘触发+阻塞:驿站只给你发一次短信,你把快递取掉之前,它不能做其他事
水平触发+非阻塞:一直给你发,它照样干其他的事。
边缘触发+非阻塞:给你发一次,它去干其他事
总结:ET一般用于高并发场景,减少重复触发开销,但需要一次性循环读取完所有数据,且设置非阻塞以免其他文件描述符无法被处理。LT适用于精确控制场景,保证数据完整性。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值