1. C++
2. 计算机网络
3. 操作系统
4. 数据库
5. 数据结构
6. 杂项
1. 进程与线程
进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发;
线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;
1.1 区别
多线程和多进程的不同
- 进程是资源分配的最小单位,线程是CPU调度的最小单位
- 一个线程从属于一个进程;一个进程可以包含多个线程。
- 一个线程挂掉,对应的进程挂掉,多线程也挂掉;一个进程挂掉,不会影响其他进程,多进程稳定。
- 进程系统开销显著大于线程开销;线程需要的系统资源更少。
- 多个进程在执行时拥有各自独立的内存单元,多个线程共享进程的内存,如代码段、数据段、扩展段;但每个线程拥有自己的栈段和寄存器组。
- 多进程切换时需要刷新TLB并获取新的地址空间,然后切换硬件上下文和内核栈;多线程切换时只需要切换硬件上下文和内核栈。
- 通信方式不一样。
- 多进程适应于多核、多机分布;多线程适用于多核。
1.2 进程
1.2.1 进程调度
- 先来先服务调度算法
- 短作业(进程)优先调度算法
- 高优先级优先调度算法
- 时间片轮转法
- 多级反馈队列调度算法
1.2.2 进程的状态
- 创建:一个应用程序从系统上启动,首先就是进入创建状态,需要获取系统资源创建进程管理块 (PCB:Process Control Block) 完成资源分配。
- 就绪:进程已处于准备好运行的状态,即进程已分配到除CPU外的所有必要资源后,只要再获得CPU,便可立即执行。
- 执行:进程已经获得CPU,程序正在执行状态。
- 阻塞:正在执行的进程由于发生某事件(如I/O请求、申请缓冲区失败等)暂时无法继续执行的状态。
- 终止:进程结束或者被系统终止,进入终止状态。
僵尸进程和孤儿进程
孤儿进程: 一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。孤儿进程并不会有什么危害
僵尸进程: 一个进程使用fork创建子进程,如果子进程退出(一般是调用exit、运行时发生致命错误或收到终止信号所导致,退出但是进程控制块PCB结构仍然驻留在内存中),而父进程长期运行,而又没有显式调用wait或者waitpid,同时也没有处理SIGCHLD信号(子进程状态改变),这个时候init进程就没有办法来替子进程收尸,这个时候,子进程就真的成了“僵尸”了。
任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。如果父进程没有调用wait()处理掉,子进程就会一直保留PCB。
僵尸进程和孤儿进程具体讲解-知乎
1.2.3 进程控制进程控制PCB-知乎
进程控制块PCB
描述进程与其他进程、系统资源的关系以及进程在各个不同时期所处的状态的数据结构,称为进程控制块PCB (process control block)。
进程的组成
PCB的作用
- PCB是进程实体的一部分,是操作系统中最重要的数据结构
- 由于它的存在,使得多道程序环境下,不能独立运行的程序成为一个能独立运行的基本单位,使得程序可以并发执行
- 系统通过PCB来感知进程的存在。(换句话说,PCB是进程存在的唯一标识)
PCB的内容
- 进程标识符 进程符号名或内部 id号
- 进程当前状态 本进程目前处于何种状态
- 当前队列指针next 该项登记了处于同一状态的下一个进程的 PCB地址。
- 进程优先级 反映了进程要求CPU的紧迫程度。
- CPU现场保护区 当进程由于某种原因释放处理机时,CPU现场信息被保存在PCB的该区域中。
- 通信信息 进程间进行通信时所记录的有关信息。
- 家族联系 指明本进程与家族的联系
- 占有资源清单
1.2.3 进程通信
-
管道:操作系统在内核中开辟一块缓冲区(称为管道)用于通信。半双工(数据只能在一个方向上流动),只能在具有公共祖先的进程之间使用,如父子进程或者兄弟进程之间,命名管道(FIFO)可在无关的进程之间交换数据。
使用pipe()函数创建。 fd[0]为读而打开,fd[1]为写而打开,fd[1]的输出是fd[0]的输入。使用read、write读写。
管道借助了文件系统的file结构和VFS的索引节点inode。通过将两个 file 结构指向同一个临时的 VFS 索引节点,而这个 VFS 索引节点又指向一个物理页面而实现的。
-
信号:信号是 Linux 进程间通信的最古老的方式之一,是事件发生时对进程的通知机制,有时也称之为软件中断,它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。
-
信号量:进程间通信处理同步互斥的机制。是在多线程环境下使用的一种设施, 它负责协调各个线程, 以保证它们能够正确、合理的使用公共资源。
信号量的本质是一个非负的整数计数器,用来控制对公有变量的访问。
int semget(key_t key, int num_sems, int sem_flags)(创建)
int semctl(int sem_id, int sem_num, int command, ...)(控制)
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops)(操作) PV操作通过该函数实现
- 消息队列:消息队列是由操作系统维护的以字节序列为基本单位的间接通信机制,每个消息是一个记录,有写权限和读权限相同标识的消息按先进先出顺序组成一个消息队列(链表)msgget
- 共享内存:共享内存允许两个或更多进程访问同一块内存,就如同malloc() 函数向不同进程返回了指向同一个物理内存区域的指针,当一个进程改变了这块地址中的内容的时候,其它进程都会察觉到这个更改。
shmget(100,sizeof(struct BUF),IPC_CREAT|0666)申请共享内存,
S=(struct BUF*)shmat(shmidl,NULL,SHM_R|SHM_W)获得共享内存地址,
semget(1111,2,IPC_CREAT|0666);
shmctl(shmid,cmd,buf); 共享内存释放
mmap可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址,对文件的读写可以直接用指针来做而不需要read/write函数
- 套接字:这是一种更为一般的进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。
nRC = WSAStartup(0x0101, &wsaData);
// 这个函数是应用程序应该第一个调用的Winsock API函数,完成对Winsock服务的初始化。
srvtcpsock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 函数执行成功返回一个新的SOCKET,失败则返回INVALID_SOCKET。这时可以调用WSAGetLastError函数取得具体的错误代码。所有的通信在建立之前都要创建一个SOCKET。
nRC=bind(srvtcpsock, (sockaddr *)&srvbindaddr, sizeof(sockaddr));
// 成功地创建了一个SOCKET后,用bind函数将SOCKET和主机地址绑定。
listen(srvtcpsock, 20);
// 对于服务器的程序,当申请到SOCKET,并将通信对象指定为INADDR_ANY之后,就应该等待一个客户机的程序来要求连接,listen函数就是把一个SOCKET设置为这个状态
SOCKET communicatesock = accept(srvtcpsock, (sockaddr*)&thrdinfo.clientaddr, &len);
// accept函数从等待连接的队列中取第一个连接请求,并且创建一个新的SOCKET来负责与客户端会话。
err = recv(communicatesock, recvbuf, MAXN, 0); InetNtop()分离出IP号
// 通过已经连接的SOCKET接收数据
int ret = send(communicatesock, sendbuf, len_snd + len, 0);
// 用send函数通过已经连接的SOCKET发送数据。
closesocket(srvtcpsock); WSACleanup();
// 关闭指定的SOCKET。
join()
// 一个等待线程函数,主线程需等待子线程运行结束后才可以结束(注意不是才可以运行,运行是并行的),如果打算等待对应线程,则需要细心挑选调用join()的位置
detach()
// 子线程的分离函数,当调用该函数后,线程就被分离到后台运行,主线程不需要等待该线程结束才结束
1.2.4 进程同步
是对多个相关进程在执行次序上进行协调,以使并发执行的诸进程之间能有效地共享资源和相互合作,从而使程序的执行具有可再现性。
(1. 锁 lock() unlock()
(2. 信号灯(s, q)与P V操作。s是一个具有非负初值的整型变量,q是一个初始状态为空的队列利用这种同步机构实现的进程同步是通过共享的存储器来实现的;这是一种较低级、简洁的通信方式。
1.2.5 进程互斥
(1)临界资源。一次仅允许一个进程使用的资源称为临界资源,如打印机、公用变量。
(2)临界区。进程中对公共变量 (或存储区)进行审查与修改的程序段,称为相对于该公共变量的临界区。
(3)互斥。
1.3 线程
1.3.1 线程通信
- 条件变量:是利用共享的变量进行线程之间同步的一种机制,如生产者-消费者模型
条件变量是一种同步机制,允许线程挂起,直到共享数据上的某些条件得到满足。条件变量上的基本操作有:触发条件(当条件变为 true 时);等待条件,挂起线程直到其他线程触发条件。 条件变量要和互斥量相联结,以避免出现条件竞争看----一个线程预备等待一个条件变量,当它在真正进入等待之前,另一个线程恰好触发了该条件。
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t* cond_attr);
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime);
int pthread_cond_destroy(pthread_cond_t *cond);
- 信号量 :是维护0到指定最大值之间的同步对象。有一个资源数,如果资源数小于最大资源数,则值减一,并且获得资源,如果等于0,则线程进入睡眠状态,直到大于0或超时。得到资源后,使用完,要释放资源,资源数加一。
类似于条件变量,都不保证信号安全
// 信号量的类型 sem_t
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
int sem_wait(sem_t *sem); // -1, == 0 等待
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
int sem_post(sem_t *sem); // + 1
int sem_getvalue(sem_t *sem, int *sval);
- 临界区,在任意时刻只允许一个线程对共享资源进行访问。并分别用EnterCriticalSection()和LeaveCriticalSection()函数去标识和释放一个临界区
- 互斥量,又名互斥锁,类似于加锁。:采用互斥对象机制,只有拥有互斥对象的线程才可以访问。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。。给临界区加锁,具有排他性
// 互斥量的类型 pthread_mutex_t
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 读写锁。
互斥量不能同时读,效率比较低。
// 读写锁的类型 pthread_rwlock_t
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
1.3.2 线程同步
线程间的同步方式包括互斥锁、信号量、条件变量、读写锁:
1.3.3 线程池
- 为什么要创建线程池:
创建线程和销毁线程的花销是比较大的,这些时间有可能比处理业务的时间还要长。这样频繁的创建线程和销毁线程,再加上业务工作线程,消耗系统资源的时间,可能导致系统资源不足。同时线程池也是为了提升系统效率。 - 线程池的核心线程与普通线程:
任务队列可以存放100个任务,此时为空,线程池里有10个核心线程,若突然来了 10个任务,那么刚好10个核心线程直接处理;若又来了 90个任务,此时核心线程来不及处理,那么有80个任务先入队列,再创建核心线程处理任务;若又来了 120个任务,此时任务队列已满,不得已,就得创建20个普通线程来处理多余的任务。 以上是线程池的工作流程。 - 实现线程池步骤:
(1) 设置一个生产者消费者队列,作为临界资源。
(2) 初始化几个线程,并让其运行起来,加锁去队列里取任务运行
(3) 当任务队列为空时,所有线程阻塞。
(4) 当生产者队列来了一个任务后,先对队列加锁,把任务挂到队列上,然后使用条件变撞去通知阻塞中的一个线程来处理。
1.4 协程
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。注意,在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似CPU的中断。
协程的特点在于是一个线程执行,那和多线程比,协程有何优势?
- 最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制(直接操作栈),因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
- 第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
因为协程是一个线程执行,那怎么利用多核CPU呢?最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
“子程序就是协程的一种特例。”
1.5 上下文切换
进程上下文切换
- 保护被中断进程的处理器现场信息
- 修改被中断进程的进程控制块有关信息,如进程状态等
- 把被中断进程的进程控制块加入有关队列
- 选择下一个占有处理器运行的进程
- 根据被选中进程设置操作系统用到的地址转换和存储保护信息
切换页目录以使用新的地址空间(刷新TLB,慢)
切换内核栈和硬件上下文(包括分配的内存,数据段,堆栈段等) - 根据被选中进程恢复处理器现场
线程上下文切换
- 保护被中断线程的处理器现场信息
- 修改被中断线程的线程控制块有关信息,如线程状态等
- 把被中断线程的线程控制块加入有关队列
- 选择下一个占有处理器运行的线程
- 根据被选中线程设置操作系统用到的存储保护信息
切换内核栈和硬件上下文(切换堆栈,以及各寄存器) - 根据被选中线程恢复处理器现场
2. 锁
2.1 死锁
在两个或者多个并发进程中,如果每个进程持有某种资源而又等待其它进程释放它或它们现在保持着的资源,在未改变这种状态之前都不能向前推进,称这一组进程产生了死锁。通俗的讲,就是两个或多个进程无限期的阻塞、相互等待的一种状态。
死锁几个场景:
- 忘记释放锁
- 重复加锁
- 多线程多锁,抢占锁资源
死锁四个条件:
- 互斥:至少有一个资源必须属于非共享模式,即一次只能被一个进程使用;若其他申请使用该资源,那么申请进程必须等到该资源被释放为止;
- 占有并等待:一个进程必须占有至少一个资源,并等待另一个资源,而该资源为其他进程所占有;
- 非抢占:进程不能被抢占,即资源只能被进程在完成任务后自愿释放
- 循环等待:若干进程之间形成一种头尾相接的环形等待资源关系
死锁的处理基本策略和常用方法
预防死锁、避免死锁、检测死锁、解除死锁 、鸵鸟策略 等。
(1). 死锁预防
死锁预防的基本思想是只要确保死锁发生的四个必要条件中至少有一个不成立,就能预防死锁的发生
- 打破互斥条件:允许进程同时访问某些资源。但是,有些资源是不能被多个进程所共享的,这是由资源本身属性所决定的,因此,这种办法通常并无实用价值。
- 打破占有并等待条件:可以实行资源预先分配策略(进程在运行前一次性向系统申请它所需要的全部资源,若所需全部资源得不到满足,则不分配任何资源,此进程暂不运行;只有当系统能满足当前进程所需的全部资源时,才一次性将所申请资源全部分配给该线程)或者只允许进程在没有占用资源时才可以申请资源(一个进程可申请一些资源并使用它们,但是在当前进程申请更多资源之前,它必须全部释放当前所占有的资源)。但是这种策略也存在一些缺点:在很多情况下,无法预知一个进程执行前所需的全部资源,因为进程是动态执行的,不可预知的;同时,会降低资源利用率,导致降低了进程的并发性
- 打破非抢占条件:允许进程强行从占有者哪里夺取某些资源。也就是说,但一个进程占有了一部分资源,在其申请新的资源且得不到满足时,它必须释放所有占有的资源以便让其它线程使用。这种预防死锁的方式实现起来困难,会降低系统性能。
- 打破循环等待条件:实行资源有序分配策略。对所有资源排序编号,所有进程对资源的请求必须严格按资源序号递增的顺序提出,即只有占用了小号资源才能申请大号资源,这样就不回产生环路,预防死锁的发生。
(2). 死锁避免的基本思想
死锁避免的基本思想是动态地检测资源分配状态,以确保循环等待条件不成立,从而确保系统处于安全状态。所谓安全状态是指:如果系统能按某个顺序为每个进程分配资源(不超过其最大值),那么系统状态是安全的,换句话说就是,如果存在一个安全序列,那么系统处于安全状态。资源分配图算法和银行家算法是两种经典的死锁避免的算法,其可以确保系统始终处于安全状态。其中,资源分配图算法应用场景为每种资源类型只有一个实例(申请边,分配边,需求边,不形成环才允许分配),而银行家算法应用于每种资源类型可以有多个实例的场景。
(3). 死锁解除
死锁解除的常用两种方法为进程终止和资源抢占。所谓进程终止是指简单地终止一个或多个进程以打破循环等待,包括两种方式:终止所有死锁进程和一次只终止一个进程直到取消死锁循环为止;所谓资源抢占是指从一个或多个死锁进程那里抢占一个或多个资源,此时必须考虑三个问题:
(I). 选择一个牺牲品
(II). 回滚:回滚到安全状态
(III). 饥饿(在代价因素中加上回滚次数,回滚的越多则越不可能继续被作为牺牲品,避免一个进程总是被回滚)
2.2 银行家算法
死锁:两个或两个以上的进程。是一个避免死锁的方法,会有三个记录,一个记录是各个进程已占有的各个资源数,一个是各个进程对各个资源最大需求量,还有就是系统资源的剩余量。当一个进程申请资源的时候,系统会检测如果满足他的要求的话,如果该状态下所有进程都可以结束运行,则该状态是安全。由系统审查现有该类资源的数目是否能满足当前进程的最大需求量,如能满足就予以分配,否则拒绝。
3. 核态、用户态
3.1 核态、用户态
区分原因:在CPU的所有指令中,有一些指令是非常危险的,如果错用,将导致整个系统崩溃。比如:清内存、设置时钟等,IO读写、内存分配。
当一个进程执行系统调用而陷入内核代码中执行时,我们就称进程处核态。此时处理器处于特权级最高的内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则处于用户态。
用户态切换核态的方式:
1.系统调用:系统调用时用户态转为内核态的唯一入口,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现。Fork send write open read
2.中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。
3.异常:当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
3.2 中断
中断是指某些事件发生时,如电源断电、加法溢出或I/O传输结束等,系统中止现行程序的运行、引出处理事件的程序对该事件进行处理,处理完毕后返回断点继续执行的过程
硬中断:是由硬件产生的,比如磁盘、网卡、键盘、时钟。
软中断:由当前正在运行的进程所产生的,I/O请求。
1.硬件中断是由外设引发的, 软中断是执行中断指令产生的.
2.硬件中断的中断号是由中断控制器提供的, 软中断的中断号由指令直接指出, 无需使用中断控制器.
3.硬件中断是可屏蔽的, 软中断不可屏蔽.
中断过程:
第一部分为准备部分,其基本功能是保护现场,相关寄存器的值进栈(中断记录表),最后开放中断,允许更高级的中断请求打断低级的中断服务程序;
第二部分为处理部分,即真正执行具体的为某个中断源服务的中断服务程序;
第三部分为结尾部分,首先要关中断,以防止在恢复现场过程中被新的中断请求打断,接着恢复现场,然后开放中断,以便返回原来的程序后可响应其他的中断请求。中断服务程序的最后一条指令一定是中断返回指令。
键盘输入到屏幕显示整个过程:
pic也就是可编程中断控制器的芯片组,负责监控设备,当有键入,就会产生一个中断,告诉CPU键盘设备有键入,这个时候CPU会倒IDT,也就是中断记录表中查找相应的中断程序,并且执行这个中断程序。而键入的中断程序的功能就是让CPU从对应的键盘端口取出数据,把数据放到显存的对应位置,
- 键盘被按下后,也就是生成 了硬件中断信号。
- 计算机中断控制器(PIC)8259A芯片捕捉到硬件中断信号
- PIC产生一个中断号,告诉CPU键盘设备有键入,并将中断号发送给中断描述符表(IDT)处理。
- 计算机根据IDT选择中断处理函数。
- 处理函数处理并通知端口驱动获取按键的信息
- 端口驱动将数据封装,以IRP(I/O request package)形式传递给上层处理程序。
- 等待输入的进程获得数据,处理并交给目标进程。
- 目标进程显示输入。
4. 存储管理
4.1 虚拟内存
每个进程都有属于自己的、私有的、地址连续的虚拟内存。页表则是记录了虚拟内存地址到物理内存地址的映射关系。当内存耗尽时,电脑就会自动调用硬盘来充当内存,以缓解内存的紧张。当RAM运行速率缓慢时,它便将数据从RAM移动到称为“分页文件”的空间中。将数据移入分页文件可释放RAM,以便完成工作。
进程切换与线程切换的区别:主要区别在于进程切换涉及到虚拟地址空间的切换,而线程不会。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间,所以同一进程中的线程进行线程切换时不涉及虚拟地址空间的切换。从虚拟地址转为物理地址需要查页表**,查页表非常慢**,需要一个cache叫TLB来辅助。当切换进程的时候,页表也要切换,TLB就会失效(两个进程的TLB不能放一起,会出现两种虚拟地址),命中率降低,导致程序变慢。
使用虚拟内存的好处和缺点
-
好处
(1) 扩大地址空间,每个进程独 一个 空间,虽然真实物理内存没那么多。
(2) 内存保护:防止不同进程对物理内存的争夺和践踏,可以对特定内存地址提供写保护,防止恶意修改。
(3) 可以实现内存共享,方便进程通信
(4) 可以避免内存碎片 虽然物理内存可能不连续,但映射到虚拟内存上可以连续 -
缺点
(1) 虚拟内存需要额外构建数据结构,占用空间。
(2) 拟地址到物 的转换,增加了执行时间
(3) 页面换入换出耗时
(4) 一页如果只有一部分数据,浪费内存。
为什么要有页表
如果将每一个虚拟内存的 Byte 都对应到物理内存的地址,每个条目最少需要8字节 (32位虚拟地址->32位物理地址),在 4G 内存的情况下,就需要 32GB 的空间来存放对照表,那么这张表就大得真正的物理地址也放不下了,于是操作系统引入了页 (Page) 的概念。
在系统启动时,操作系统将整个物理内存以 4K 为单位,划分为各个页之后进行内存分配时,都以页为单位,那么虚拟内存页对应物理内存页的映射表就大大减小了, 4G 内存,只需要 8M 的映射表即可,一些进程没有使用到的虚拟内存,也并不需要保存映射关系,而且Linux 还为大内存设计了多级页表,可以进一页减少了内存消耗。
4.2 页式、段式存储
页式:将各进程的虚拟空间划分为若干个长度相等的页,把内存空间按页的大小划分,然后把页式虚拟地址与内存地址建立一一对应的页表,不要求作业连续存放,有效解决外部碎片问题
页号 | 块号 | 中断位 | 辅存地址 | 引用数 | 改变位 |
需要解决的问题:缺页中断->缺页处理->淘汰算法
常用页面置换算法:最佳算法(OPT算法)、先进先出淘汰算法(FIFO算法)、最久未使用淘汰算法(LRU算法)
LeetCode 146 - LRU缓存机制实现
段式: 把一个程序分成若干个段进行存储,每个段都是逻辑实体,比如代码分段、数据分段、栈段,段长是可变的,会有段表。为了满足用户编程,段共享,内存保护等
- bss BSS段:用来存放程序中未初始化的全局/静态变量的一块内存区域。属于静态内存分配。
- data 数据段:用来存放程序中已初始化的全局/静态变量的一块内存区域。属于静态内存分配。
(合为全局(静态)存储区)程序在一开始(编译) - text 代码段:指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读常量,例如字符串常量等。
- heap 堆段:堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)若程序员不释放,则会有内存泄漏,系统会不稳定,Windows系统在该进程退出时由OS释放,Linux则只在整个系统关闭时OS才去释放(参考Linux内存管理)。
- stack 栈段:栈又称堆栈, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变 量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
段页式:在段式存储中结合页式,在一个分段内划分页面,用分段的方法分配和管理用户地址空间,用分页方法来管理物理存储空间。
缺页中断和缺页异常
缺页异常: malloc和mmap 函数在分配内存时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常,引发缺页中断。
缺页中断:缺页异常后将产生一个缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存
缺页中断和一般中断异同
缺页中断与一般中断一样,需要经历四个步骤 保护CPU现场、分析中断原因、转入缺页中断处理程序、恢复CPU现场,继续执行。
缺页中断与一般中断区别: 1) 在指令执行期间产生和处理缺页中断信号 2) 一条指令在执行期间,可能产生多次缺页中断 3) 缺页中断返回的是执行产生中断的一条指令,而一般中断返回的是执行下一条指令。
4.3 malloc工作过程
- brk是将数据段(.heap)的最高地址指针_edata往高地址推;
- mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。
由于brk/sbrk/mmap属于系统调用,如果每次申请内存,都调用这三个函数中的一个,那么每次都要产生系统调用开销(即cpu从用户态切换到内核态的上下文切换,这里要保存用户态数据,等会还要切换回用户态),这是非常影响性能的;其次,这样申请的内存容易产生碎片,因为堆是从低地址到高地址,如果低地址的内存没有被释放,高地址的内存就不能被回收。
malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址。
粗略总结:
- malloc开始搜索空闲内存块,如果能找到一块大小合适的就分配出去
- 如果malloc找不到一块合适的空闲内存,那么调用brk()(<128K)或mmap()(>128K)等系统调用扩大堆区从而获得更多的空闲内存
- malloc调用brk后开始转入内核态,此时操作系统中的虚拟内存系统开始工作,扩大进程的堆区,注意额外扩大的这一部分内存仅仅是虚拟内存,操作系统并没有为此分配真正的物理内存
- brk执行结束后返回到malloc,从内核态切换到用户态,malloc找到一块合适的空闲内存后返回,程序继续
- 当有代码读写新申请的内存时系统内部出现缺页中断,此时再次由用户态切换到内核态,操作系统此时真正的分配物理内存,之后再次由内核态切换回用户态,程序继续。
malloc分配详细过程
glibc过程:
- 分配内存 < DEFAULT_MMAP_THRESHOLD,走__brk,从内存池获取,失败的话走brk系统调用
- 分配内存 > DEFAULT_MMAP_THRESHOLD,走__mmap,直接调用mmap系统调用
其中,DEFAULT_MMAP_THRESHOLD默认为128k,可通过mallopt进行设置。
重点看下小块内存(size < DEFAULT_MMAP_THRESHOLD)的分配,glibc使用的内存池如下图示:
关键描述:
malloc将相似大小的chunk用双向链表链接起来,这样一个链表被称为一个bin。malloc一共维护了128个bin,并使用一个数组来存储这些bin。数组中第一个为unsorted bin,用于维护free释放的chunk,数组从2开始编号,前64个bin为small bins,同一个small bin中的chunk具有相同的大小,两个相邻的small bin中的chunk大小相差8B。small bins后面的bin被称作large bins。large bins中的每一个bin分别包含了一个给定范围内的chunk,其中的chunk按大小序排列。large bin的每个bin相差64字节。
内存池保存在bins这个长128的数组中,每个元素都是一双向个链表。其中:
- bins[0]目前没有使用
- bins[1]的链表称为unsorted_list,用于维护free释放的chunk。
- bins[2,63)的区间称为small_bins,用于维护<512字节的内存块,其中每个元素对应的链表中的chunk大小相同,均为index*8B。
- bins[64,127)称为large_bins,用于维护**>512字节的内存块,每个元素对应的链表中的chunk大小不同**,index越大,链表中chunk的内存大小相差越大,例如: 下标为64的chunk大小介于[512, 512+64),下标为95的chunk大小介于[2k+1,2k+512)。同一条链表上的chunk,按照从小到大的顺序排列。
chunk数据结构
- prev_size 表示前一个 chunk 的 size,程序可以使用这个值来找到前一个 chunk 的开始地址。
- chunk 的第二个域的最低一位为 P,它表示前一个块是否在使用中,P 为 0 则表示前一个 chunk 为空闲,这时chunk的第一个域 prev_size 才有效,当 P 为 1 时,表示前一个 chunk 正在使用中,prev_size程序也就不可以得到前一个 chunk 的大小。不能对前一个 chunk 进行任何操作。malloc分配的第一个块总是将 P 设为 1,以防止程序引用到不存在的区域。
- Chunk 的第二个域的倒数第二个位为 M,他表示当前 chunk 是从哪个内存区域获得的虚拟内存。M 为 1 表示该 chunk 是从 mmap 映射区域分配的,否则是从 heap 区域分配的。
- Chunk 的第二个域倒数第三个位为 A,表示该 chunk 属于主分配区或者非主分配区,如果属于非主分配区,将该位置为 1,否则置为 0。
- 指针fd指向后一个空闲的chunk,而bk指向前一个空闲的chunk,malloc通过这两个指针将大小相近的chunk连成一个双向链表
free释放内存时,有两种情况:
- chunk和top chunk相邻,则和top chunk合并
- chunk和top chunk不相邻,则直接插入到unsorted_list中
top chunk
如下图示: top chunk是堆顶的chunk,堆顶指针brk位于top chunk的顶部。移动brk指针,即可扩充top chunk的大小。当top chunk大小超过128k(可配置)时,会触发malloc_trim操作,调用sbrk(-size)将内存归还操作系统。
chunk分布图:
整体流程:
glibc在内存池中查找合适的chunk时,采用了最佳适应法。
- 如果分配内存<512字节,则通过内存大小定位到smallbins对应的index上(floor(size/8))
如果smallbins[index]为空,进入步骤3
如果smallbins[index]非空,直接返回第一个chunk - 如果分配内存>512字节,则定位到largebins对应的index上
如果largebins[index]为空,进入步骤3
如果largebins[index]非空,扫描链表,找到第一个大小最合适的chunk,如size=12.5K,则使用chunk B,剩下的0.5k放入unsorted_list中 - 遍历unsorted_list,查找合适size的chunk,如果找到则返回;否则,将这些chunk都归类放到smallbins和largebins里面
- index++从更大的链表中查找,直到找到合适大小的chunk为止,找到后将chunk拆分,并将剩余的加入到unsorted_list中
- 如果还没有找到,那么使用top chunk
- 或者,内存<128k,使用brk;内存>128k,使用mmap获取新内存
4.4 内存碎片
- 内部碎片是由于采用固定大小的内存分区,当一个进程不能完全使用分给它的固定内存区域时就产生了内部碎片,通常内部碎片难以完全避免;堆是从低地址到高地址,如果低地址的内存没有被释放,高地址的内存就不能被回收。
- 外部碎片是由于某些未分配的连续内存区域太小,以至于不能满足任意进程的内存分配请求,从而不能被进程利用的内存区域。
解决办法:内存池
- 一次申请一大块区域,再将这块区域分成不同的小块,每次要用的时候将小块分配出去,释放的时候大块一起释放
- 用完内存及时释放;
- 使用内存池,就可以按自己需要来管理内存,比如对于申请小片的内存就不要使用新的内存快,而是使用之前用过已经释放的,这样如果要申请大空间那就可以申请也会减少碎片。
- 段页式内存,将进程的内存区域分为不同的段,然后将每一段由多个固定大小的页组成。通过页表机制,使段内的页可以不必连续处于同一内存区域,从而减少了外部碎片,然而同一页内仍然可能存在少量的内部碎片,只是一页的内存空间本就较小,从而使可能存在的内部碎片也较少。
4.5 文件系统(详解)
1. 数据区块(block)
真正存放文件的地方。
2. inode
存储内容:
特点:
结构示意图:
inode本身128B,inode记录一个数据区块需要4B,数据区块按照1KB计算,总共存储量=12+256+256256+256256*256=16GB
3. 超级区块(superblock)
记录信息:
5. 与目录树的关系
5. 硬链接和软链接
-
命令 ln
-
硬链接: 硬链接是在某个目录下新增一条文件名链接到某inode号码的关联记录。硬连接指通过索引节点 inode 产生新文件名而不是新文件,即每一个硬链接都是一个指向对应区域的文件。
可以透过1或2的目录之inode指定的block找到两个不同的文件名,而不管使用那个文件名均可指到real那个inode去读取到最终数据。
优点: 安全,删除任何一个档名(上图中的1或2),inode和block都是存在的,此外无论使用哪个档名编辑,最终结果都会写入inode和block中。
特点: 一般不增加inode和block数量;不能跨文件系统;不能链接目录(复杂) -
软链接(符号链接):等同于Windows快捷方式,建立一个单独的文件,而这个文件会让数据的读取指向它连接的那个文件的文件名。两个文件是不同的inode号码,且链接文件保存的内容就是目标文件的文件名。
5. 杂项
5.1 同步&异步 阻塞&非阻塞
- 同步与异步的区别:
- 数据库:
同步: 是所有的操作都做完,才返回给用户结果。即写完数据库之后,再响应用户,用户体验不好。
异步: 不用等所有操作都做完,就响应用户请求。即先响应用户请求,然后慢慢去写数据库,用户体验较好。 . - 操作系统:
同步: 内核通知用户数据到了,然后用户自己调用相应函数去接收数据。
异步: 内核将数据拷贝到用户区,不需要用户再自已接收数据,直接使用就可以了,而
- 阻塞与非阻塞的区别:
阻塞: 调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作。
非阻塞: 非阻塞等待,每隔一段时间就去检查IO事件是否就绪。没有就绪就可以做其他事情。
5.2 IO模型的类型
- 阻塞IO: 调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作。
- 非阻塞IO: 非阻塞等待,每隔一段时间就去检查IO事件是否就绪。没有就绪就可以做其他事情。
- 信号驱动IO: Linux用套接口进行信号驱动IO, 安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO信号,然后处理IO事件。
- IO 多路复用: Linux select/poll 函数实现IO 复用模型,这两个函数也会使进程阻塞,但是和阻塞I0不同的是这两个函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO 函数进行检查。知道有数据可读或可写时,才真正调用 IO操作函数。
- 异步IO: Linux 中,可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。用户可以直接去使用数据。
前四种模型:阻塞IO、非阻塞IO、多路复用 IO和信号驱动IO都属于同步模式,因为其中真正的IO操作(函数)都将会阻塞进程,只有异步IO模型真正实现了 IO操作的异步性。
异步和同步的区别就在于,异步是内核将数据拷贝到用户区,不需要用户再自已接收数据,直接使用就可以了,而同步是内核通知用户数据到了,然后用户自己调用相应函数去接收数据。
5.3 IO多路复用 Select poll epoll(详细讲解)
网络IO可以抽象成用户态和内核态之间的数据交换。使用select、epoll等操作系统提供的系统调用来检测IO事件的各种机制,获取到处于活跃状态的连接。
- Select:
1)创建所关注的事件的描述符集合(fd_set),对于一个描述符,可以关注其上面的读(read)、写(write)、异常(exception)事件,所以通常,要创建三个fd_set,一个用来收集关注读事件的描述符,一个用来收集关注写事件的描述符,另外一个用来收集关注异常事件的描述符集合。
2)调用select()等待事件发生。这里需要注意的一点是,select的阻塞与是否设置非阻塞I/O是没有关系的。
3)轮询所有fd_set中的每一个fd,检查是否有相应的事件发生,如果有,就进行处理。
Select缺点
1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大!!!(复制大量句柄数据结构,产生巨大的开销 )。
2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大!!!(消耗大量时间去轮询各个句柄,才能发现哪些句柄发生了事件)。
3)单个进程能够监视的文件描述符的数量存在最大限制,32位机默认是1024。
4)select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。 - Poll:
与select差不多,采用链表的方式替换原有fd_set数据结构,没有连接数的限制。 - Epoll:
将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程(解耦)
1)通过epoll_create建立epoll句柄。
2)将描述符所感兴趣的事件通过epoll_ctl添加到等待队列中
3)调用epoll_wait返回所有可读写的描述符。
双向列表列表引用着就绪的 socket,接收内核触发的事件,所以它应能够快速的插入数据。使用双向链表来实现就绪队列redlist,可以快速删除和插入
epoll 使用了红黑树作为索引结构,保存监视的 socket,至少要方便地添加和移除,还要便于搜索,以避免重复添加。
我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。
触发方式:
ET(边缘触发):只有当句柄状态改变时,边缘触发才会发出通知。也就是发生读写事件后,只会通知一次。Fd就会在就绪队里中删除
LT(水平触发):只要句柄满足某种状态,水平触发就会发出通知。也就是当epoll_wait检测到fd上有事件发生并将此事件通知应用程序后不会删除,而下一次调用epoll_wait的时候会继续通知
ET比LT高效的原因:因为fd通知之后没有被删除,所以就绪队列会特别长,遍历要时间;还有就是ET模式在很大程度上降低了同一个epoll事件被重复触发的次数。
5.4 main函数之前执行了什么
全局对象的构造函数会在main 函数之前执行。
①设置栈指针
②初始化静态和全局变量,即data段的内容
③将未初始化部分的赋初值:数值型short,int,long等为0,bool为FALSE,指针为NULL,等等,即.bss段的内容
④运行全局构造器,估计是C++中构造函数之类的吧
⑤将main函数的参数,argc,argv等传递给main函数,然后才真正运行main函数