从图上我们可以看出来通过系统调用将Linux整个体系分为用户态和内核态(或者说内核空间和用户空间)。那内核态到底是什么呢?其实从本质上说就是我们所说的内核,它是一种特殊的软件程序,特殊在哪儿呢?控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。
其实用一句话就能概括:它们权限不同。用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”。一个进程可以运行在用户态也可以运行在内核态,那它们之间肯定存在用户态和内核态切换的过程。打一个比方:C库接口malloc申请动态内存,malloc的实现内部最终还是会调用brk()或者mmap()系统调用来分配内存。
那为问题又来了,从用户态到内核态到底怎么进入?只能通过系统调用吗?还有其他方式吗?
从用户态到内核态切换可以通过三种方式:
操作系统有虚拟内存与物理内存之分。在虚拟内存出现之前,程序寻址用的都是物理地址,因此程序能寻址的范围是有限的,具体程序可以寻址的范围有多大取决于CPU的地址线条数。比如在32位平台下,寻址的范围是2^32也就是4G,并且这是固定的,如果没有虚拟内存,且每次开启一个进程都分配出来4G的物理内存,就会出现很多问题:
· 因为物理内存是有限的,当有多个进程要执行的时候,对每个进程都要分配4G内存,很显然你内存若小一点,这很快就分配完了,于是没有得到分配资源的进程就只能等待。当一个进程执行完后,再将等待的进程装入内存。这种频繁的装入内存的操作是很没效率的。
· 由于指令都是直接访问物理内存的,那么进程就可以修改其他进程的数据,甚至会修改内核地址空间的数据,这是我们不想看到的。
· 因为内存是随机分配的,所以程序运行的地址也是不正确的。
为了解决上述问题,于是就出现了虚拟内存。
关系:一个进程运行时会被分配4G的虚拟内存。进程有了虚拟内存后,每个进程都认为自己拥有4G的内存空间,当然这只是每个进程认为的。但实际上,虚拟内存对应的实际物理内存,可能只对应的分配了一点点的物理内存,实际使用了多少内存,就会对应多少物理内存。
进程得到的这4G虚拟内存是一个连续的地址空间(这也只是进程认为),而实际上,它的数据是存储在多个物理内存碎片的,还有一部分存储在外部磁盘存储器上,在需要时将数据交换进物理内存。
进程开始要访问一个地址,它可能会经历下面的过程
\1. 进程每次要访问地址空间上的某一个地址时,都需要把地址翻译为实际物理内存地址。
\2. 所有进程共享一整块物理内存,每个进程只把自己目前需要访问的虚拟地址空间映射到物理内存上。
\3. 进程需要知道哪些虚拟内存地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),若在物理内存上存在,则需要进一步知道数据存储在物理内存上的具体位置,这都需要通过页表来记录。
\4. 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)。
\5. 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常。
\6. 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。
当每个进程创建的时候,内核会为进程分配4G的虚拟内存,当进程还没有开始运行时,这只是一个内存布局。实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射关系(叫做存储器映射)。这个时候数据和代码还是在磁盘上的。当运行到对应的程序时,进程去访问页表,发现页表中地址没有存放在物理内存上,而是在磁盘上,于是发生缺页异常,于是将磁盘上的数据拷贝到物理内存中。
kill命令用于终止Linux进程,默认情况下,如果不指定信号,kill 等价于kill -15。
kill -15执行时,系统向对应的程序发送SIGTERM(15)信号,该信号是可以被执行、阻塞和忽略的,所以应用程序接收到信号后,可以做一些准备工作,再进行程序终止。
有的时候,kill -15无法终止程序,因为他可能被忽略,这时候可以使用kill -9,系统会发出SIGKILL(9)信号,该信号不允许忽略和阻塞,所以应用程序会立即终止。
这也会带来很多副作用,如数据丢失等,所以,在非必要时,不要使用kill -9命令,尤其是那些web应用、提供RPC服务、执行定时任务、包含长事务等应用中,因为kill -9 没给spring容器、tomcat服务器、dubbo服务、流程引擎、状态机等足够的时间进行收尾。
管道的本质就是在内核中开辟的一段空间,这段空间也可以称之为缓冲区,并且这段空间是没有标识符的
数据的拷贝过程——管道需要在内核和用户空间进行四次的数据拷贝:由用户空间的buf中将数据拷贝到内核中 -> 内核将数据拷贝到内存中 -> 内存到内核 -> 内核到用户空间的buf。
匿名管道只允许单向通信
匿名管道自带互斥同步机制
匿名管道只能用于有血缘关系的进程,常用于父子进程间通信
匿名管道是面向字节流的
管道的生命周期随通信双方的进程
4种情况:
当管道的读端不读且不关闭时,写端会一直写,写满时,写端会出现阻塞式等待
当管道的写端不写也不关闭时,读端会一直读,读完时,读端也会出现阻塞式等待
当管道的读端不读且读文件描述符关闭时,写端会退出
当管道的写端不读并且关闭时,读端也会退出
共享内存是一种最为高效的进程间通信方式,进程可以直接读写内存,而不需要任何数据的拷贝。它是IPC对象的一种。
为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的 私有地址空间。进程就可以直接读写这一内存区而不需要进行数据的拷贝,从而大大提高的效率。
方法一、利用POSIX有名信号灯实现共享内存的同步
方法二、利用POSIX无名信号灯实现共享内存的同步
方法三、利用System V的信号灯实现共享内存的同步
方法四、利用信号实现共享内存的同步
ELK日志分析系统:ELK是由Elasticsearch Logstash Kibana三大组件构成的一个基于web页面的日志分析工具。
日志分析是运维工程师解决系统故障,发现问题的主要手段。日志包含多种类型,包括程序日志,系统日志以及安全日志等。通过对日志分析,预发故障的发生,又可以在故障发生时,寻找到蛛丝马迹,快速定位故障点。及时解决。
组件结构:
Elasticsearch:是一个开源分布式时实分析搜索引擎,建立在全文搜索引擎库Apache Lucene基础上,同时隐藏了Apache Lucene的复杂性。Elasticsearch将所有的功能打包成一个独立的动画片,索引副本机制,RESTful风格接口,多数据源。自动搜索等特点。
Logstash :是一个完全开源的工具,主要用于日志收集,同时可以对数据处理,并输出给Elasticarch
Kibana:也是一个完全开源的工具,kibana可以为Logstash和Elasticsearch提供图形化的日志分析。Web界面,可以汇总,分析和搜索重要数据日志。
消息队列和管道基本上都是4次拷贝,而共享内存(mmap, shmget)只有两次。
4次:1,由用户空间的buf中将数据拷贝到内核中。2,内核将数据拷贝到内存中。3,内存到内核。4,内核到用户空间的buf.
2次: 1,用户空间到内存。 2,内存到用户空间。
消息队列和管道都是内核对象,所执行的操作也都是系统调用,而这些数据最终是要存储在内存中执行的。因此不可避免的要经过4次数据的拷贝。但是共享内存不同,当执行mmap或者shmget时,会在内存中开辟空间,然后再将这块空间映射到用户进程的虚拟地址空间中,即返回值为一个指向一个内存地址的指针。当用户使用这个指针时,例如赋值操作,会引起一个从虚拟地址到物理地址的转化,会将数据直接写入对应的物理内存中,省去了拷贝到内核中的过程。当读取数据时,也是类似的过程,因此总共有两次数据拷贝。
概念:
进程是运行时程序的封装。是操作系统进行资源分配和调度的基本单位,它实现了操作系统内部的并发。
线程:线程是进行的一个子任务,是cpu可以识别和执行的最小单位,保证了程序内部的并发。同一进程中的所有线程都有自己的任务,但是他们共享同一地址空间,打开的文件队列和其他资源。
区别:
\1. 一个进程最少有一个线程,也可多个线程。一个线程只能属于一个进程。线程依赖于进程存在(火车,火车车厢)。
\2. 进程是操作系统进行资源分配和调度的基本单位,线程是CPU调度的最小单位。
\3. 不同的进程都有自己的地址空间,相互之间是独立的。但是多个线程共享同一地址空间,堆,代码段,数据段。但是每个线程都有自己的栈空间,栈段也叫运行时的段。
\4. 进程创建,切换或者销毁的系统开销都是远大于线程的。创建和销毁时候操作系统需要给进程分配或者回收相应的地址空间和IO资源等,因此创建或者切换的开销都是远大于线程的。切换时候,操作系统需要对整个CPU环境的保存,以及对于新环境的设置。而线程切换只要保存少量寄存器内容即可。
\5. 通信方式:线程间通信的方式非常简单,因为他们共享了地址空间,比如有数据段,所以可以很轻易的通过全局变量来进行线程间的通信。但是需要一些保证同步和互斥的操作,保障数据的一致性。
\6. 一个进程挂掉,一般不会影响到别的进程。但是一个线程挂了,会导致所在的整个进程挂掉。
同步通信:
进程通信:
\1. 管道:PIPE和FIFO。PIPE只能半双工的,想要数据的双向流动就需要创建两个进行通信。在有父
子和兄弟进程之间使用。FIFO没有这个限制。
\2. 系统IPC:共享内存,消息队列,信号,信号量。
\3. 套接字通信:SOCKET。
线程通信:
\1. 通过临界区的数据。
\2. 互斥量。不会被多个线程访问。
\3. 信号量。会被做个线程访问。
\4. 事件。通知操作,提供了线程优先级比较。
临界资源:同一时刻只允许一个进程(线程)访问的资源,叫临界资源。
临界区:访问临界资源的代码段叫临界区。
第一步:CPU段式管理中——逻辑地址转线性地址
CPU要利用其段式内存管理单元,先将为个逻辑地址转换成一个线程地址。
一个逻辑地址由两部份组成,【段标识符:段内偏移量】。
段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
通过段标识符中的索引号从GDT或者LDT找到该段的段描述符,段描述符中的base字段是段的起始地址GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
段起始地址+ 段内偏移量 = 线性地址
第二步:页式管理——线性地址转物理地址
CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。
线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。
另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。
每个进程都有自己的页目录,当进程处于运行态的时候,其页目录地址存放在cr3寄存器中。
每一个32位的线性地址被划分为三部份,【页目录索引(10位):页表索引(10位):页内偏移(12位)】
依据以下步骤进行转换:
\1. 从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
\2. 根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,
不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
\3. 根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
\4. 将页的起始地址与线性地址中最后12位相加。
页面置换算法: 地址映射过程中,当CUP需要访问的页面不在内存中,则发生缺页中断!当缺页中断发生后,内存中没有空闲的页面,则必须选择一个页面将其移出内存(如果选择的页面上的数据没有被修改,则直接用新页覆盖,如果修选择的页面上的数据被修改了,则必须将其置换到虚拟空间上),为访问的页面腾出空间。选择移出页面的算法称为页置换算法。
设计页面置换算法的目标:
1、 降低随后发生缺页中断的次数或者概率。选择的页面在随后相当长时间不会被访问到,最好是再也不会被访问。 2、应该选择一个没有修改过的页面,这样,替换时就无需将被替换的页面内容写回到磁盘,从而进一步加快缺页中断的响应时间。
常用的页面置换算法有:
1、OPT(最佳置换算法)
2、FIFO(先进先出置换算法)
3、第二次机会算法(Second Chance)
4、NRU(最近未使用算法)
5、 LRU(最近最少使用置换算法)
6、时钟(CLOCK)置换算法
7、工作集算法
8、工作集时钟算法
死锁的概念:多个进程或线程访问一组竟态资源的时候,出现的永久阻塞的问题。产生的原因主要有三个:系统资源不足,程序运行推进的顺序不当,资源分配不当。
产生的四个条件是:
1) 互斥::进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源;
2) 请求与保持:进程获得一定的资源后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但该进程不会释放自己已经占有的资源。
3) 不可抢占:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放
4) 循环等待:进程发生死锁后,必然存在一个进程-资源之间的环形链
解决死锁的方法即破坏上述四个条件之一,主要方法如下:
资源一次性分配,从而剥夺请求和保持条件。如某个进程申请多个资源,只要有一个资源不满足暂时就不要分配任何资源。等所有资源能满足时一起分配。
可剥夺资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可剥夺的条件
资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反,从而破坏环路等待的条件
系统调用和普通函数的最大区别就在于执行需要的权限不同。普通函数可以由用户空间去直接执行,而系统调用必须由内核帮用户执行。也就是说:发起系统调用,从用户态切换到内核态,内核执行完毕函数之后,再将执行结果返回给用户,从内核态切换回去用户态。(系统调用的开销要大于普通函数)
为什么要有系统调用?为了操作系统的安全性,我们把直接对与IO设备等等一些操作,都设计为系统调用,也就是只能由操作系统内核帮我们去执行,提高了操作系统的安全性问题。
了进程间通讯,Linux下进程间的通讯方式有: 信号、管道(有名、无名)、信号量、消息队列、共享内存、socket等。
1: 信号
信号是系统预先定义好的一些特定的事件,信号可以被产生,也可以被接收,产生和接收的主体都是进程。接收到信号的进程会采取相应的一些行动。一般信号是由于某些错误条件而生成的,比如内存段冲突、浮点处理器错误或非法指令等。信号也可以作为进程间传递消息或修改行为的一种方式,明确地由一个进程发送给另一个进程。接收到信号的进程有三种响应方式,分别为: 忽略 (SIG_IGN) 默认(SIG_DFL) 自定义/捕获(自定义的函数)
进程修改信号响应方式的函数:
typedef void (*Fun_Handle_t)(int);
Fun_Handle_t signal(int sig, Fun_Handle_t fun);
```
进程发送信号的函数:int kill(pid_t pid, int sig); //向指定进程发送指定的信号
```
B: 管道
管道是进程间传递数据的一种通讯方式,管道分为有名管道和无名管道,有名管道在文件系统中会生成一个管道文件。但是这个文件只是一个文件标识,而不占据磁盘空间,所以可以在任意两个进程间完成通讯。无名管道则是利用父子进程共享fork之前打开的文件描述符,所以只能应用于父子进程之间。
有名管道的使用:
创建: 1、 命令: mkfififo fifilename
2、函数: int mkfifo(const char *filename, mode_t mode);
打开: int open(const char *filename, int flag, /mode_t mode/);
读取内容: int read(int fd, void *buf, int size);
写内容: int write(int fd, void *buf, int len);
关闭: int close(int fd);
无名管道的使用:
```c++
```c++ 创建: int pipe(int fds[2]); // 此函数必须在fork之前调用,作用是创建一个无名管道,并使fds[0]
指向其读端, fds[1]指向其写端
读取内容: int read(int fd, void *buf, int size);
写内容: int write(int fd, void *buf, int len);
关闭: int close(int fd);
C: 信号量
信号量可以完成进程间的同步控制,(进程同步就是一个进程的执行必须等待另一个进程使某种条件的发生)。信号量相当于一个特殊的计数器,在其值大于零时,记录资源能被几个进程使用,当其值小于零时,记录等待资源的进程数量。当一个进程使用资源之前必须先对信号量的值进行减一操作(P操作),然后判断其是否大于或等于零,如果小于零,必须等待。而当一个进程使用完资源之后,必须对信号量的值进行加一操作(V操作),意味着自己将资源释放出来。
信号量的操作:
创建或获取: int semget(key_t key, int nsems, int flag);
P、V操作: int semop(int semid, struct sembuf semoparray[], size_t nops);
操作控制: int semctl(int semid, int semnum, int cmd, /*union semun
arg*/);
D: 消息队列
消息队列也可以实现进程间传递数据,不过传递的数据是带有type的,不同的进程可以根据需要获取不同类型的数据,如果队列中同一类型的数据有多条,则采用先进先出的原则获取数据。
消息队列的操作:
创建或获取: int msgget(key_t key, int flag);
发送消息: int msgsnd(int msgid, const void *msg_ptr, size_t msg_sz, int
msgflg);
获取消息:
int msgrcv(int msgid, void *msg_ptr, size_t msg_sz, long int msgtype, int
msgflg);
控制操作: int msgctl(int msgid, int cmd, struct msgid_ds *buf);
E: 共享内存
共享内存是用同一块物理内存映射到不同的两个进程上的虚拟地址空间上来完成进程间通讯的。在每个进程中,都可以将这块空间看做自己的一部分,使用时,少了用户空间数据与内核创建的空间拷贝的过程,所以共享内存是最快的一种IPC。
共享内存的使用:
创建或获取: int shmget(key_t key, size_t size, int flag);
映射到虚拟地址空间: void * shmat(int shmid, void *addr, int flag);
断开映射: int shmdt(void *shm_addr); 控制操作: int shmctl(int shmid, int cmd, struct shmid_ds *buf);
F:socket****网络编程,通过网络实现不同主机上的两个进程间的通讯
pv操作都是对信号量而言的,p是对信号量的值进行原子减一,代表获取资源,当信号量的值为0时,p操作会阻塞,意味着资源不可用。
v操作是对信号量的值进行原子加一,代表释放资源,v操作从不阻塞。
多进程:
多进程优点:
1、每个进程互相独立,不影响主程序的稳定性,子进程崩溃没关系;
2、通过增加CPU,就可以容易扩充性能;
3、可以尽量减少线程加锁/解锁的影响,极大提高性能,就算是线程运行的模块算法效率低也没关系;
4、每个子进程都有2GB地址空间和相关资源,总体能够达到的性能上限非常大。
**多进程缺点:**1、逻辑控制复杂,需要和主程序交互;
2、需要跨进程边界,如果有大数据量传送,就不太好,适合小数据量传送、密集运算 多进程调度开销比较大;
3、最好是多进程和多线程结合,即根据实际的需要,每个CPU开启一个子进程,这个子进程开启多线程可以为若干同类型的数据进行处理。当然你也可以利用多线程+多CPU+轮询方式来解决问题……
4、方法和手段是多样的,关键是自己看起来实现方便有能够满足要求,代价也合适。
多线程:
多线程的优点:
1、无需跨进程边界;
2、程序逻辑和控制方式简单;
3、所有线程可以直接共享内存和变量等;
4、线程方式消耗的总资源比进程方式好。
多线程缺点:
1、每个线程与主程序共用地址空间,受限于2GB地址空间;
2、线程之间的同步和加锁控制比较麻烦;
3、一个线程的崩溃可能影响到整个程序的稳定性;
4、到达一定的线程数程度后,即使再增加CPU也无法提高性能,例如Windows Server 2003,大约是1500个左右的线程数就快到极限了(线程堆栈设定为1M),如果设定线程堆栈为2M,还达不到1500个线程总数;
5、线程能够提高的总性能有限,而且线程多了之后,线程本身的调度也是一个麻烦事儿,需要消耗较多的CPU。多进程模型的优势是CPU多线程模型主要优势为线程间切换代价较小,因此适用于I/O密集型的工作场景,因此I/O密集型的工作场景经常会由于I/O阻塞导致频繁的切换线程。同时,多线程模型也适用于单机多核分布式场景。多进程模型,适用于CPU密集型。同时,多进程模型也适用于多机分布式场景中,易于多机扩展。
每个进程都有自己的页目录,当进程处于运行态的时候,其页目录地址存放在cr3寄存器中。
每一个32位的线性地址被划分为三部份,【页目录索引(10位):页表索引(10位):页内偏移(12位)】
依据以下步骤进行转换:
从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
将页的起始地址与线性地址中最后12位相加。
malloc()和mmap()等内存分配函数,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常。
缺页异常:在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存是,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。
缺页需要经过4个处理步骤:
1、保护CPU现场
2、分析中断原因
3、转入缺页中断处理程序进行处理
4、恢复CPU现场,继续执行
但是缺页是由于所要访问的页面不存在于内存时,由硬件所产生的一种特殊的中断,因此,与一般的中断存在区别:
1、在指令执行期间产生和处理缺页中断信号
2、一条指令在执行期间,可能产生多次缺页中断
3、缺页中断返回是,执行产生中断的一条指令,而一般的中断返回是,执行下一条指令。
除了子进程必须要立刻执行一次对exec的系统调用,或者调用_exit( )退出,对vfork( )的成功调用所产生的结果和fork( )是一样的。vfork( )会挂起父进程直到子进程终止或者运行了一个新的可执行文件的映像。通过这样的方式,vfork( )避免了地址空间的按页复制。在这个过程中,父进程和子进程共享相同的地址空间和页表项。实际上vfork( )只完成了一件事:复制内部的内核数据结构。因此,子进程也就不能修改地址空间中的任何内存。
vfork( )是一个历史遗留产物,Linux本不应该实现它。需要注意的是,即使增加了写时复制,vfork( )也要比fork( )快,因为它没有进行页表项的复制。然而,写时复制的出现减少了对于替换fork( )争论。实际上,直到2.2.0内核,vfork( )只是一个封装过的fork( )。因为对vfork( )的需求要小于fork( ),所以vfork( )的这种实现方式是可行的。
后来fork引入了写时拷贝技术,同样可以解决fork+exec系列产生的不需要拷贝问题,所以vfork成了历史的产物
并发(concurrency):指宏观上看起来两个程序在同时运行,比如说在单核cpu上的多任务。但是从微观上看两个程序的指令是交织着运行的,你的指令之间穿插着我的指令,我的指令之间穿插着你的,在单个周期内只运行了一个指令。这种并发并不能提高计算机的性能,只能提高效率。
并行(parallelism):指严格物理意义上的同时运行,比如多核cpu,两个程序分别运行在两个核上,两者之间互不影响,单个周期内每个程序都运行了自己的指令,也就是运行了两条指令。这样说来并行的确提高了计算机的效率。所以现在的cpu都是往多核方面发展。
互斥锁:mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒
读写锁:rwlock,分为读锁和写锁。处于读操作时,可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。
注意:写锁会阻塞其它读写锁。当有一个线程获得写锁在写时,读锁也不能被其它线程获取;写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。适用于读取数据的频率远远大于写数据的频率的场合。
自旋锁:spinlock,在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗,在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源。
1)正常进程
正常情况下,子进程是通过父进程创建的,子进程再创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。 当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到:在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息,直到父进程通过wait / waitpid来取时才释放。保存信息包括:
1进程号the process ID
2退出状态the termination status of the process
3运行时间the amount of CPU time taken by the process等
2)孤儿进程
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
3)僵尸进程
一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。
僵尸进程是一个进程必然会经过的过程:这是每个子进程在结束时都要经过的阶段。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。
如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。
危害:
如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程。
外部消灭:
通过kill发送SIGTERM或者SIGKILL信号消灭产生僵尸进程的进程,它产生的僵死进程就变成了孤儿进程,这些孤儿进程会被init进程接管,init进程会wait()这些孤儿进程,释放它们占用的系统进程表中的资源
内部解决:
1、子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程。
2、fork两次,原理是将子进程成为孤儿进程,从而其的父进程变为init进程,通过init进程可以处理僵尸进程。
简单来说,系统过程的过程可以分为下面两个部分:
【1】产生0x80号中断,保存当前进程现场信息,将该系统调用的系统调用号写入eax寄存器中。【2】中断处理程序执行,也即切换到了内核态,此处也就是执行系统调用的程序,从eax寄存器中读出系统调用号,查系统调用表,找到对应的内核函数执行,将结果继续写入eax寄存器中。中断处理程序执行完成,恢复原程序的执行,也即回到了用户态。应用程序从eax寄存器中读取返回值。
下面为更为详细的描述:
整个过程如下:首先指令流执行到系统调用函数时,系统调用函数通过int 0x80指令进入系统调用入口程序,并且把系统调用号放入%eax中,如果需要传递参数,则把参数放入ebx,ecx和%edx中。进入系统调用入口程序(System_call)后,它首先把相关的寄存器压入内核堆栈(以备将来恢复),这个过程称为保护现场。保护现场的工作完成后,开始检查系统调用号是不是一个有效值,如果不是则退出。
接下来根据系统调用号开始调用系统调用处理程序(这是一个正式执行系统调用功能的函数),从系统调用处理程序返回后,就会去检查当前进程是否处于就绪态、进程时间片是否用完,如果不在就绪态或者时间片已用完,那么就会去调用进程调度程序schedule(),转去执行其他进程。如果不执行进程调度程序,那么接下来就会开始执行ret_from_sys_call,顾名思义,这这个程序主要执行一些系统调用的后处理工作。比如它会去检查当前进程是否有需要处理的信号,如果有则去调用do_signal(),然后进行一些恢复现场的工作,返回到原先的进程指令流中。至此整个系统调用的过程就结束了。
send和recv是利用建立好的tcp连接进行发送数据和接收数据的系统调用。send负责将要发送的数据写入对应套接字文件描述符的发送缓冲区,send成功并不能说明数据发送到了对端,它的返回值是实际写入发送缓冲区的字节数,什么时候发送给对端由底层协议完成。
如果缓冲区满则有可能阻塞send。send在在内核中最终通过__sock_sendmsg,将数据写入相应的缓冲区。
recv是从文件描述符对应的接收缓冲区,读取数据,读取多少由缓冲区当前数据量和应用程序期望的读取的字节数决定,取小者。recv在内核中最终通过__sock_recvmsg,从缓冲区读到数据,再拷贝到用户空间。
实模式和保护模式本质来讲寻址方式,和寻址范围不同。
【1】实模式:以8086为代表,物理地址=段寄存器<<4 + 偏移量 。对内存的访问没有任何的保护措施,给出合适的段基地址和偏移量就可访问整个内存地址空间。
【2】保护模式:以80386为代表,段寄存器得到的不是段基地址,而是段描述符表的下标,及当前
是处于内核态还是用户态,有权限的控制。通过查段描述符表可以得到 “段的基地址”,然后 段的基地址+ 偏移量 =线性地址。
此处得到的线性地址并不是直接作用与物理地址,而是通过分页的方式,将线性地址再映射到物理页面得到物理地址,在这一步会有读写等等权限的控制在里面。
1、Future模型
该模型通常在使用的时候需要结合Callable接口配合使用。
Future是把结果放在将来获取,当前主线程并不急于获取处理结果。允许子线程先进行处理一段时间,处理结束之后就把结果保存下来,当主线程需要使用的时候再向子线程索取。
Callable是类似于Runnable的接口,其中call方法类似于run方法,所不同的是run方法不能抛出受检异常没有返回值,而call方法则可以抛出受检异常并可设置返回值。两者的方法体都是线程执行体。
2、fork&join模型
该模型包含递归思想和回溯思想,递归用来拆分任务,回溯用合并结果。可以用来处理一些可以进行拆分的大任务。其主要是把一个大任务逐级拆分为多个子任务,然后分别在子线程中执行,当每个子线程执行结束之后逐级回溯,返回结果进行汇总合并,最终得出想要的结果。
这里模拟一个摘苹果的场景:有100棵苹果树,每棵苹果树有10个苹果,现在要把他们摘下来。为了节约时间,规定每个线程最多只能摘10棵苹树以便于节约时间。各个线程摘完之后汇总计算总苹果树。
3、actor模型
actor模型属于一种基于消息传递机制并行任务处理思想,它以消息的形式来进行线程间数据传输,避免了全局变量的使用,进而避免了数据同步错误的隐患。actor在接受到消息之后可以自己进行处理,也可以继续传递(分发)给其它actor进行处理。在使用actor模型的时候需要使用第三方Akka提供的框架。
4、生产者消费者模型生产者消费者模型都比较熟悉,其核心是使用一个缓存来保存任务。开启一个/多个线程来生产任务,然后再开启一个/多个来从缓存中取出任务进行处理。这样的好处是任务的生成和处理分隔开,生产者不需要处理任务,只负责向生成任务然后保存到缓存。而消费者只需要从缓存中取出任务进行处理。使用的时候可以根据任务的生成情况和处理情况开启不同的线程来处理。比如,生成的任务速度较快,那么就可以灵活的多开启几个消费者线程进行处理,这样就可以避免任务的处理响应缓慢的问题。
5、master-worker模型
master-worker模型类似于任务分发策略,开启一个master线程接收任务,然后在master中根据任务的具体情况进行分发给其它worker子线程,然后由子线程处理务。如需返回结果,则worker处理结束之后把处理结果返回给master。
编译
\1. 编译的第一步就是将.cpp文件进行预编译,预编译要处理的就是以#开头的代码,比如 #include .#define , #ifdef 等等。但是我们一定要注意,并不是所有以开头的代码都会在这个时期进行处理,比如下面这样的代码
第一段代码#progma lib:处理程序需要连接的库 并不是编译阶段处理的,需要存活到连接的阶段
第二段代码#progma link:修改程序的入口地址,同样需要存活到链接阶段。
宏的展开是有一些副作用的,这里我们一定要注意,具体内容参考c语言学习
\2. 编译的第二步就是进行代码的编译: g++ -O 1 2 3 这样的操作可是选定编译优化的优先级别。这
个阶段会生成一些相应平台所对应的汇编指令,
\3. 编译的第三步就是进行汇编:汇编会生成符号表和符号。过后的结果会生成一个二进制的可重定位的目标文件,这里一定要注意,并不是直接生成了可执行文件,只是.o(linux)或者.obj(window)的可重定位的二进制文件。整个编译过程就到这里结束。
链接:
首先需要知道,链接会链接所有完成编译的.o文件+静态库文件.lib,但是并不会链接动态库,动态库是在程序执行阶段进行动态链接的。
\1. 链接第一步:所有文件进行段的合并,符号表生成以后,进行符号解析。
\2. 链接第二步:符号的重定位(重定向)
1、静态链接:
函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
#pragma lib
#pragma link
运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
2、动态链接:
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分,副本,而是这多个程序在执行时共享同一份副本;
更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
这其实类似于linux内核中的RCU(read-copy-update)锁,RCU的核心理念其实就是读线程访问时,写线程可以去更新保护数据的副本,但写线程需要等待所有读线程完成读取后,才可以删除老对象。那么可以通过这种思想,在用户级实现一个类似的锁来解决读写锁带来的问题。
在现代的计算机系统中,通常总是采用由三种运行原理不同,性能差异很大的存储介质分别构建高速缓冲存储器、主存储器和虚拟存储器,再将它们组成三级结构的统一管理、调度的一体化存储器系统。如图所示:
高速缓存一般会分为三级,我们称之为CPU的三级缓存机制。
CPU缓存的作用。
1、缩短延迟
访问缓存的时间应该尽可能缩短,可以通过多种的方式缩短这个时间,比如能够通过减小缓存的大小或关联性来降低缓存的延迟,还有方式预测、增加带宽等方法。
2、提升命中率所谓的命中率是在高速缓存中找到内存引用的速率,我们希望能够首先通过缓存中获得信息,以得到速度优势,所以缓存需要最大限度地实现这一目标。对于单个高速缓存,大小、关联性和块大小决定命中率。
3、降低更低级别内存下的开销
高速缓存是内存层次结构的一部分,其性能会影响其它性能,处理其它内存花费的时间越长,意味着系统性能越低,也就是说尽可能让处理在缓存中完成。
1)TLB的概述
TLB是一个内存管理单元用于改进虚拟地址到物理地址转换速度的缓存.
TLB是位于内存中的页表的cache,如果没有TLB,则每次取数据都需要两次访问内存,即查页表获得物理地址和取数据.
2)tlb的原理
当cpu对数据进行读请求时, CPU根据虚拟地址(前20位)到TLB中查找 .
TLB中保存着虚拟地址(前20位)和页框号的对映关系 ,如果匹配到虚拟地址就可以迅速找到页框号,通过页框号与虚拟地址后12位的偏移组合得到最终的物理地址.页框号可以理解为页表项如果没在TLB中匹配到虚拟地址,就出现TLB丢失,需要到页表中查询页表项,如果不在页表中,说明要读取的内容不在内存,需要到磁盘读取.
TLB是MMU中的一块高速缓存,也是一种Cache.在分页机制中,TLB中的数据和页表的数据关联,不是由处理器维护,而是由OS来维护,TLB的刷新是通过装入处理器中的CR3寄存器来完成.如果MMU发现在TLB中没有命中,它在常规的页表查找后,用找到的页表项替换TLB中的一个条目.
3)tlb的刷新原则
当进程进行上下文切换时重新设置cr3寄存器,并且刷新tlb.
有两种情况可以避免刷tlb.
第一种情况是使用相同页表的进程切换.
第二种情况是普通进程切换到内核线程.
lazy-tlb(懒惰模式)的技术是为了避免进程切换导致tlb被刷新.
当普通进程切换到内核线程时,系统进入lazy-tlb模式,切到普通进程时退出该模式.
我们可以通过uptime
,w
或者top
命令看到CPU的平均负载
平均负载:代表的是当前系统正在运行的和处于等待运行的进程数之和。也指的是处于可运行状态和不可中断状态的平均进程数。
如果是单核CPU的话,负载达到1就代表CPU已经达到满负荷的状态了,超过1,后面的进程就需要排队等待处理了。
如果是多核多CPU的话,假设现在服务器是2个CPU,每个CPU2个核,那么总负荷不超过4都没什么问题。
通过命令cat /proc/cpuinfo | grep "model name"
查看CPU的情况。
CPU利用率:和负载不同,CPU利用率指的是当前正在运行的进程实时占用CPU的百分比,它是对一段时间内CPU使用状况的统计。
这样情况说明处于等待状态的任务很多,负载很高,代表可能很多僵死的进程。通常这种情况是IO密集型的任务,大量请求在请求相同的IO,导致任务队列堆积。
同样,可以先通过top
命令观察,假设发现现在确实是高负载低使用率。
然后,再通过命令ps -axjf
查看是否存在状态为D+
状态的进程,这个状态指的就是不可中断的睡眠状态的进程。处于这个状态的进程无法终止,也无法自行退出,只能通过恢复其依赖的资源或者重启系统来解决。
这表示CPU的任务并不多,但是任务执行的时间很长,大概率就是你写的代码本身有问题,通常是计算密集型任务,生成了大量耗时短的计算任务。
怎么排查?直接top
命令找到使用率最高的任务,定位到去看看就行了。如果代码没有问题,那么过段时间CPU使用率就会下降的。
top
找到占用率高的进程。top -Hp pid
找到占用CPU高的线程ID。这里找到958的线程IDprintf "0x%x\n" 958
,得到线程ID0x3be
jstack 163 | grep '0x3be' -C5 --color
或者 jstack 163|vim +/0x3be -
找到有问题的代码常用的文件、目录命令
ls:用户查看目录下的文件,ls -a
可以用来查看隐藏文件,ls -l
可以用于查看文件的详细信息,包括权限、大小、所有者等信息。
touch
:用于创建文件。如果文件不存在,则创建一个新的文件,如果文件已存在,则会修改文件的时间戳。
cat
:cat是英文concatenate
的缩写,用于查看文件内容。使用cat
查看文件的话,不管文件的内容有多少,都会一次性显示,所以他不适合查看太大的文件。
more
:more和cat有点区别,more用于分屏显示文件内容。可以用空格键
向下翻页,b
键向上翻页
less
:和more类似,less用于分行显示
tail
:可能是平时用的最多的命令了,查看日志文件基本靠他了。一般用户tail -fn 100 xx.log
查看最后的100行内容
常用的权限命令
chmod
:修改权限命令。一般用+
号添加权限,-
号删除权限,x
代表执行权限,r
代表读取权限,w
代表写入权限,常见写法比如chmod +x 文件名
添加执行权限。
还有另外一种写法,使用数字来授权,因为r
=4,w
=2,x
=1,平时执行命令chmod 777 文件名
这就是最高权限了。
第一个数字7=4+2+1代表着所有者的权限,第二个数字7代表所属组的权限,第三个数字代表其他人的权限。
常见的权限数字还有644,所有者有读写权限,其他人只有只读权限,755代表其他人有只读和执行权限。
chown
:用于修改文件和目录的所有者和所属组。一般用法chown user 文件
用于修改文件所有者,chown user:user 文件
修改文件所有者和组,冒号前面是所有者,后面是组。
常用的压缩命令
zip
:压缩zip文件命令,比如zip test.zip 文件
可以把文件压缩成zip文件,如果压缩目录的话则需添加-r
选项。
unzip
:与zip对应,解压zip文件命令。unzip xxx.zip
直接解压,还可以通过-d
选项指定解压目录。
gzip`:用于压缩.gz后缀文件,gzip命令不能打包目录。需要注意的是直接使用`gzip 文件名`源文件会消失,如果要保留源文件,可以使用`gzip -c 文件名 > xx.gz`,解压缩直接使用`gzip -d xx.gz
tar
:tar常用几个选项,-x
解打包,-c
打包,-f
指定压缩包文件名,-v
显示打包文件过程,一般常用tar -cvf xx.tar 文件
来打包,解压则使用tar -xvf xx.tar
。
Linux的打包和压缩是分开的操作,如果要打包并且压缩的话,按照前面的做法必须先用tar打包,然后再用gzip压缩。当然,还有更好的做法就是-z
命令,打包并且压缩。
使用命令tar -zcvf xx.tar.gz 文件
来打包压缩,使用命令tar -zxvf xx.tar.gz
来解压缩
ls:用户查看目录下的文件,ls -a
可以用来查看隐藏文件,ls -l
可以用于查看文件的详细信息,包括权限、大小、所有者等信息。
touch
:用于创建文件。如果文件不存在,则创建一个新的文件,如果文件已存在,则会修改文件的时间戳。
cat
:cat是英文concatenate
的缩写,用于查看文件内容。使用cat
查看文件的话,不管文件的内容有多少,都会一次性显示,所以他不适合查看太大的文件。
more
:more和cat有点区别,more用于分屏显示文件内容。可以用空格键
向下翻页,b
键向上翻页
less
:和more类似,less用于分行显示
tail
:可能是平时用的最多的命令了,查看日志文件基本靠他了。一般用户tail -fn 100 xx.log
查看最后的100行内容
cd /home 进入 ‘/ home’ 目录’
cd … 返回上一级目录
cd …/… 返回上两级目录
cd 进入个人的主目录
cd ~user1 进入个人的主目录
cd - 返回上次所在的目录
pwd 显示工作路径
ls 查看目录中的文件
ls -F 查看目录中的文件
ls -l 显示文件和目录的详细资料
ls -a 显示隐藏文件
ls [0-9] 显示包含数字的文件名和目录名
tree 显示文件和目录由根目录开始的树形结构(1)
lstree 显示文件和目录由根目录开始的树形结构(2)
mkdir dir1 创建一个叫做 ‘dir1’ 的目录’
mkdir dir1 dir2 同时创建两个目录
mkdir -p /tmp/dir1/dir2 创建一个目录树
rm -f file1 删除一个叫做 ‘file1’ 的文件’
rmdir dir1 删除一个叫做 ‘dir1’ 的目录’
rm -rf dir1 删除一个叫做 ‘dir1’ 的目录并同时删除其内容
rm -rf dir1 dir2 同时删除两个目录及它们的内容
mv dir1 new_dir 重命名/移动 一个目录
cp file1 file2 复制一个文件
cp dir/* . 复制一个目录下的所有文件到当前工作目录
cp -a /tmp/dir1 . 复制一个目录到当前工作目录
cp -a dir1 dir2 复制一个目录
ln -s file1 lnk1 创建一个指向文件或目录的软链接
ln file1 lnk1 创建一个指向文件或目录的物理链接
touch -t 0712250000 file1 修改一个文件或目录的时间戳 - (YYMMDDhhmm)
file file1 outputs the mime type of the file as text
iconv -l 列出已知的编码
通常是以 4 个指标来衡量网络的性能,分别是带宽、延时、吞吐率、PPS(Packet Per Second),它们表示的意义如下:
当然,除了以上这四种基本的指标,还有一些其他常用的性能指标,比如:
阻塞型IO:最简单的一种IO模型,简单理解就是死等,即进程或线程一直等待莫格条件,不满足则一直
等待。
非阻塞型IO:应用进程与内核交互,目的未达到之前会直接返回,然后不断轮询,不停的去问内核数据
是否准备好?如果发现准备好了,那就把数据拷贝到用户空间中。应用进程通过 recvfrom 调用不停的
去和内核交互,直到内核准备好数据。如果没有准备好,内
核会返回error,应用进程在得到error后,过一段时间再发送recvfrom请求。在两次发送请求的时间
段,进程可以先做别的事情。
信号驱动IO:我们会发现非阻塞型IO方式一遍一遍的轮询不如等内核把数据准备好,然后通知进程,当
进程收到该通知时,便开始把数据拷贝到用户空间中。
即应用进程预先向内核注册一个信号处理函数,然后用户进程返回,并不阻塞,当内核数据准备就绪时
会发送一个信号给进程,用户进程便在信号处理函数中开始把数据拷贝到用户空间中
IO复用模型:顾名思义,即将多个进程I/0注册到同一管道上,这里管道会统一和内核交互。当管道中的
某一个请求需要好的数据准备好之后,进程再把对应的数据拷贝到用户空间中。
I/O多路转接是多了一个Select函数,多个进程的IO可以注册到同一个Select中,用户调用该Select。
Select会监听所有注册好的I/O,如果所有被监听的I/O需要的数据都没有准备好,Select调用进程会阻
塞。当任意一个I/O所需要的数据准备好之后,Select调用就
会返回,然后进程再通过recvfrom来进行数据拷贝。但实际上,它并未向内核注册信号处理函数,所
以它并不是非阻塞的。
大家肯定会有疑问,为什么之前的这四种模型都是同步的呢?因为无论以上哪种模型,真正的数据拷贝
过程都是同步的(自己的理解便是:所有的数据拷贝过程都是用户进程手动执行的)
那么我们来看真正异步执行的I/O模型:
异步I/O模型:应用进程把I/O请求传给内核后,完全由内核去操作文件拷贝。内核完成相关操作后,会
发信号告诉应用进程本次I/O已经完成。用户进程发起aio_read操作之后,给内核传递描述符、缓冲区
指针、缓冲区大小等,告诉内核进程当整个操作完成时,如何通知进程,然后就立刻去做其他事儿了。
当内核收到aio_read后,会立刻返回,然后内核开始等待数据准备,数据准备好以后,直接把数据拷贝
到用户控件,然后再通知进程本次IO已经完成。
虚拟内存技术使得不同进程在运行过程中,它所看到的是自己独自占有了当前系统的4G内存。所有进程
共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。 事实上,在
每个进程创建加载时,内核只是为进程“创建”了虚拟内存的布局,具体就是初始化进程控制表中内存相
关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理
内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射),等到运行到对应的程序
时,才会通过缺页异常,来拷贝数据。还有进程运行过程中,要动态分配内存,比如malloc时,也只是
分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺
页异常。
虚拟内存的好处:
1.扩大地址空间;
2.内存保护:每个进程运行在各自的虚拟内存地址空间,互相不能干扰对方。虚存还对特定的内存地址
提供写保护,可以防止代码或数据被恶意篡改。
3.公平内存分配。采用了虚存之后,每个进程都相当于有同样大小的虚存空间。
4.当进程通信时,可采用虚存共享的方式实现。
5.当不同的进程使用同样的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码,不
同的进程只需要把自己的虚拟内存映射过去就可以了,节省内存
6.虚拟内存很适合在多道程序设计系统中使用,许多程序的片段同时保存在内存中。当一个程序等待它
的一部分读入内存时,可以把CPU交给另一个进程使用。在内存中可以保留多个进程,系统并发度提高
7.在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间分配连续空间,而不需要实际物理内
存的连续空间,可以利用碎片
虚拟内存的代价:
1.虚存的管理需要建立很多数据结构,这些数据结构要占用额外的内存
2.虚拟地址到物理地址的转换,增加了指令的执行时间。
3.页面的换入换出需要磁盘I/O,这是很耗时的
4.如果一页中只有一部分数据,会浪费内存。
Linux的文件系统:
Linux操作系统的文件子系统采用的是索引式文件系统Ext2, 将磁盘格式化成Ext2文件系统格式的时
候,会划分出三个区域: superblock 、 inode 、 block。
superblock:记录文件系统的详细信息以及inode&block的总量、使用量和剩余量。
inode:记录文件的属性信息以及文件的权限信息。
block: 存储文件的真实数据。
Linux上的文件类型:
Linux上一切皆文件,而Linux系统将文件分为以下几类:
普通文件(-)
目录文件(d)
链接文件(l)
管道文件(p)
设备文件(b:块设备 c:字符设备 s:套接字)
为了解决文件共享问题,Linux引入了软链接和硬链接。除了为Linux解决文件共享使用,还带来了隐藏文件路径、增加权限安全及节省存储等好处。若1个inode号对应多个文件名,则为硬链接,即硬链接就是同一个文件使用了不同的别名,使用ln创建。若文件用户数据块中存放的内容是另一个文件的路径名指向,则该文件是软连接。软连接是一个普通文件,有自己独立的inode,但是其数据块内容比较特殊
Linux实现线程的方式非常独特,对于Linux内核来说,并没有线程这个概念。Linux把所有的线程都当做进程来实现。内核并没有准备相应的调度算法或者定义特别的数据结构来表示线程。线程仅仅被视为一个与其他进程贡献某些资源的进程而已。每个线程都有自己的task_struct,所以在内核中,看起来和普通进程没什么差异
为什么为提出零拷贝:
通常我们会有这样的需求:将本地磁盘上的一个文件通过网络发送给远端的另一个服务。在传统的I/O中,会经过下面几个步骤:
\1. 发出read()系统调用,这时处理器会从用户空间切换至内核空间;
\2. 向磁盘请求数据;
\3. 通过DMA将文件从磁盘上读取到内核空间缓冲区;
\4. read()系统调用返回,将数据从内核空间缓冲区拷贝至用户空间缓冲区,这时候处理器会从内核空间切换至用户空间;
\5. 发出write()系统调用,并将数据从用户空间缓冲区拷贝至目标socket 在内核空间的缓冲区,这时候处理器会从用户空间切换至内核空间;
\6. write()调用返回;
\7. 通过DMA将数据从内核空间缓冲区中拷贝至协议引擎(该操作是独立且异步的)。
总的来说:传统的I/O操作在整个过程中将会产生4次上下文切换和4次数据拷贝。
零拷贝的实现:
Linux 中提供类似的系统调用主要有 sendfifile()、mmap() 和splice()。
通过sendfifile实现
sendfifile系统调用在内核版本2.1中被引入,目的是简化通过网络在两个本地文件之间进行的数据传输过
程。sendfifile系统调用的引入,不仅减少了数据复制,还减少了上下文切换的次数。
\1. 发出sendfifile()系统调用,这时处理器会从用户空间切换至内核空间;
\2. 向磁盘请求数据;
\3. 通过DMA将文件从磁盘上读取到内核空间缓冲区;
\4. 将数据从内核空间缓冲区拷贝到目标socket缓冲区;
\5. Sendfifile()返回,这时处理器从内核空间切换至用户空间;
\6. 通过DMA将数据从目标socket缓冲区拷贝至协议引擎。
总结一下这种实现,整个过程产生了2次上下文切换和3次数据拷贝(其中2次DMA拷贝和1次CPU拷贝)。
该实现虽然减少了2次上下文切换,但仍然还有1次CPU拷贝。那这次拷贝是不是也可以省掉呢?答案是肯定的。但是需要底层操作系统的一些支持。那就是带有DMA收集功能的sendfifile实现的零拷贝。
有DMA收集功能的sendfifile实现的零拷贝
操作系统底层提供了带有scatter/gather的DMA来从内核空间缓冲区中将数据读取到协议引擎中。这就意味着等待传输的数据不需要在连续存储器中,它可以分散在不同的内存位置。那这样一来,从文件中读出的数据就不必拷贝至目标socket的缓冲区中,只需要将缓冲区描述符添加到目标socket的缓冲区中,DMA收集操作会根据缓冲区描述符中的信息将内核空间缓冲区中的数据读取到协议引擎。这种方法不仅减少了上下文切换、还减少了由CPU参与的数据拷贝。
\1. 发出sendfifile()系统调用,处理器从用户空间切换至内核空间;
\2. 通过DMA将数据copy至内核空间缓冲区;
\3. 将数据在内核空间缓冲区的地址和偏移量拷贝至目标socket的缓冲区;
\4. Sendfifile()返回,处理器从内核空间切换至用户空间。
\5. 带有scatter/gather 功能的DMA将数据直接从内核缓冲区读取到协议引擎,从而消除了最后一次CPU拷贝。
总结一下,这种方法产生了2次上下文切换和2次数据拷贝。(减少了一次数据拷贝)。
总结sendfifile的缺点所在:如果我把数据从磁盘上读出来后,再编辑一下,再发送出去,以上所说的零拷贝则不能实现。针对这个问题,linux内核为我们提供了mmap方法。
通过mmap实现的零拷贝
mmap(内存映射):mmap操作提供了一种机制,让用户程序直接访问设备内存,这种机制,相比较在用户空间和内核空间互相拷贝数据,效率更高。
\1. 发出mmap()系统调用,处理器从用户空间切换至内核空间。
\2. 向磁盘请求数据;
\3. 通过DMA将数据从磁盘拷贝至内核空间缓冲区;
\4. mmap()调用返回,这时候用户程序和操作系统共享这个缓冲区,不需要再将数据从kernel buffffer拷贝至 user buffffer,处理器从内核空间切换至用户空间;
\5. 用户逻辑处理;
\6. 发出write()系统调用,将数据从内核空间缓冲区拷贝至目标socket缓冲区,这时处理器从用户空间切换至内核空间;
\7. write()调用返回,处理器从内核空间切换至用户空间;
\8. 通过DMA将数据拷贝至协议引擎。使用****splicesendfifile只适用于将数据从文件拷贝到套接字上,限定了它的使用范围。
在两个文件描述符之间传输数据,不用拷贝。但输入和输出文件描述符必须有一个是pipe。也就是说如果你需要从一个socket 传输数据到另外一个socket,是需要使用 pipe来做为中介的。 pipe buffffer被抽象出来,当作 “内核缓存结构”, 一种流缓冲,可以理解成你的数据从写入 “内核流缓存”里面,然后在从一个”内核流缓存“复制到另外一个比如说socket的缓存。全部数据都是在内核空间进行。 当然你的数据复制也是不用复制,他那个pipe buffffer本来就是 使用page去管理缓存的,就是 缓存地址加偏移地址的办法,只是Linus 觉splice的需要很像之前的pipe思想,所以splice就用这个个pipe来作为”内核缓存结构“了。