文章目录
操作系统是管理计算机硬件与软件资源的计算机程序。操作系统是计算机中负责支撑应用程序运行环境以及用户操作环境的系统软件。
三、Linux 系统
1、Linux 多进程编程
1.1 linux 进程概述
在linux中进程的发展过程及其关系如下图所示:
在linux中,存在三个特殊的进程,idle
进程(PID=0)
,init
进程(PID=1)
,kthreadd
进程(PID=2)
(这里不做介绍)。这三个进程是Linux系统的基础。
① idle 进程(0号进程)
idle进程是唯一一个没有通过fork()产生的进程,idle进程完成了linux内核的初始化工作,包括初始化页表,初始化中断向量表,初始化系统时间,然后调用fork(),创建第一个用户进程init
进程。完成加载系统后,idle进程演变成进程调度,交换。
② init 进程(1号进程)
当linux内核启动后,就会创建init进程。因此,init 进程是一个由内核启动的用户级进程,且init进程始终是第一个进程,其进程编号始终为1。init进程会创建其他系统进程,从而完成系统各个服务的启动。在系统启动完成后,init进程会变成守护进程监视系统其他进程。
1.2 linux 进/线程的底层原理
💗 1.2.1 进程控制块 - PCB(进程描述符)
1. 通过PCB创建进程
进程控制块PCB
,又叫进程描述符。为了控制和描述进程的运行,描述进程的当前情况以及控制进程运行的全部信息,系统中存放进程的管理和控制信息的数据结构称为进程控制块(PCB)
。每一个进程均有一个PCB,在创建进程时,建立PCB,伴随进程运行的全过程,直到进程撤销而撤销。PCB的作用是使一个在多道程序环境下不能独立运行的程序(含数据),成为一个能独立运行的基本单位,一个能与其他进程并发执行的进程。
2. PCB的本质 -task_struct
对于Linux来说,进程
PCB
就是一个数据结构(
task_struct
),该数据结构就是Linux内核对进程的描述,也称为进程描述符。
在Linux中,进程与线程基本上没有区别,无论进程还是线程,都是用task_struct
结构来表示的,唯一的区别就是共享的数据区域不同。
task_struct=进程,除此之外,还需要进程的管理,如下图为进程的管理中最主要的5个链表:
💗 1.2.2 进程内存描述符
在进程描述符task_struct
中,其中有一个成员mm_struct
,该成员是进程的内存描述符,用来管理进程的地址空间信息。一个进程的虚拟空间主要由mm_struct
和vm_area_struct,rb_root
来描述:
1. mm_struct - 逻辑地址
mm_struct
表示一个进程的整个虚拟地址空间(逻辑地址),并将整个虚拟地址空间划分为6个段,从低地址到高地址分别是:Text Segement(代码段)
,Data Segment(数据段)
,Bss Segment(未初始化全局变量段)
,Heap Segment(堆段)
,Mmap Segment(内存映射段)
,Stack segment(栈段)
① Text 段(正文段):
代码段用来存放指令,运行代码的一块内存空间,此空间大小在代码运行前就已经确定,一般属于只读,在代码段中,包含一些只读的常数变量,例如字符串常量等。Text段存放在磁盘程序文件中。
② Data 段(初始化数据段):
数据段可读可写,存储初始化的global
全局变量和初始化的static
变量。数据段中数据的生存期跟随进程,进程创建就存在,进程死亡就消失。Data 段存放在磁盘程序文件中。
③ Rodata 段:
只读数据,Rodata
是在多个进程间是共享的,但常量const
不一定就放在Rodata
里,有的立即数直接编码在指令里,存放在代码段.text
中
④ BSS 段(未初始化数据段):
BSS段可读可写,存储未初始化的global
全局变量和未初始化的static
变量,BSS段中数据的生存期跟随进程,进程创建就存在,进程死亡就消失。BSS段中的数据一般默认为0或空指针。
⑤ Heap 段:
可读可写,存储的是程序运行期间动态分配的malloc/new
的空间,堆的生存期随进程持续性,从malloc/new
到free/delete
一直存在。
⑥ Stack 段:
存放函数每次调用时保存的信息,以及被调用函数中的局部变量。
Notice:在操作系统层面与C/C++层面,内存的分配方式是不同的,其对应的区别如下图所示:
2. vm_area_structs、rb_root - 线性地址
线性地址是逻辑地址到物理地址变换之间的中间层,线性地址=段基地址+段中偏移地址
。在mm_struct
中对线性地址的分配和管理有两种方式:
① 当虚拟地址空间较少时,采用单链表 struct vm_area_structs *mmap
来管理,vm_area_structs
描述了虚拟地址空间中的一个线性地址区间。每个线性区间都对应一个vm_area_structs
,通过*vm_next
指针组成升序单链表。
② 当虚拟空间较大时,为了快速的查找和定位线性区间,采用红黑树struct rb_root mm_rb
来管理,以每个线性区间的起始地址作为索引Key,mm_rb
指向线性空间中红黑树的根。
💗 1.2.3 子进程创建过程
在Linux中有三个创建进程或线程的函数fork()
,vfork()[线程]
,clone()
,三个函数分别调用了sys_fork
、sys_vfork
、sys_clone
,最终都调用了do_fork()
函数。
1. fork()
通过fork()
创建子进程时,为了提高效率,linux引入了“写时复制技术 Copy-On-Write”
,其创建过程如下:
① 调用fork()
后,子进程完全复制父进程的栈空间,也复制了页表,但是没有复制物理页面,所以fork()
创建子线程后,父进程与子进程的虚拟地址是不同的,但物理地址相同,且会把父子进程的共享页面标记为只读。
② 当任何一个进程对共享页面写操作时,内核会复制一个物理页面给子进程,同时修改页表,并将原来的只读页面标记为可写,留给父进程使用。
2. vfork()
vfork()
创建的子进程
共享父进程当前栈帧的空间,没有自己独立的内存资源,不是真正意义上的进程。
在vfork()
创建子进程后,父进程阻塞,子进程在父进程的地址空间中运行,直到子进程执行了exec()
或exit()
。
Q1. 为什么vfork创建子进程后,父进程会阻塞 ?
因为
vfork()
主要用于为了让子进程
exec
,
exec
之后子进程会用新程序的数据将内存重新刷一遍,这样它就有了自己的地址空间。子进程
exec
之后,会向父进程发送信号,这个时候父进程就可以开始运行了,如果子进程修改了父进程地址空间的话,父进程唤醒的时候就会发现自己的数据被改了,完整性丢失,所以这是不安全的。除此之外,
如果vfork()
的子进程在调用exec或exit
之前依赖于父进程的进一步动作,则会造成死锁。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int glob = 88; //a global var
void foo(int);
int main(int argc,char *arg[]){
int var = 100; //a local var in main
foo(var);
if(printf("In main var:%d glob:%d pid:%d/n",var,glob,getpid())<0)
perror("main printf");
exit(0);
}
void foo(int var){
pid_t pid;
int loc = 66; //a local var in foo
printf("Before vfork/n");
if((pid = vfork())<0)
perror("vfork");
else if(pid == 0){ //child process
loc++;
var++;
glob++;
printf("pid:%d/n",getpid());
exit(0);
}
printf("In foo var:%d glob:%d loc:%d pid:%d/n",var,glob,loc,getpid());
//In main val:100 glob:89 pid:10212
//In foo val:101 glob:89 loc:67 pid:10212
}
3. clone()
clone()
可以根据参数,自由的创建进程或线程,且有选择性的继承父进程的资源,既可以和父进程共享一个空间,也可以创建独立的空间。clone()
与fork()
的区别如下:
① clone()
和fork()
的调用方式很不相同,clone()
调用需要传入一个函数,该函数在子进程中执行。
② clone()
和fork()
最大不同在于clone()
不再复制父进程的栈空间,而是自己创建一个新的。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <sched.h>
#define FIBER_STACK 8192
int a;
void* stack;
int do_something(){
a=10;
printf("This is son, the pid is:%d, the a is: %d\n", getpid(), a);
free(stack);
exit(1);
}
int main() {
void* stack;
a = 1;
stack = malloc(FIBER_STACK);//为子进程申请系统堆栈
if(!stack) {
printf("The stack failed\n");
exit(0);
}
printf("creating son thread!!!\n");
clone(&do_something, (char *)stack + FIBER_STACK, CLONE_VM|CLONE_VFORK, 0);//创建子线程
printf("This is father, my pid is: %d, the a is: %d\n", getpid(), a);
exit(1);
}
1.3 父进程,子进程与孤儿进程,僵尸进程
在linux中通过fork()
函数在已经存在的进程中创建子进程,创建的子进程是父进程的 “复制(Copy-On-Write)
”,子进程完全复制了父进程的资源,包括进程上下文、代码区、数据区、堆区、栈区、内存信息、打开文件的文件描述符、信号处理函数、进程优先级、进程组号、当前工作目录、根目录、资源限制和控制终端等信息。子进程与父进程不同的是进程号,资源使用情况和计时器。注意:父进程与子进程的运行没有先后顺序。
僵尸进程: 当子进程退出,且父进程没有调用
wait()
或
waitpid()
时,子进程就会成为僵尸进程。僵尸进程的PCB仍然保存在系统中,它会占用进程描述符,会导致进程描述符资源耗尽。其解决方法有两种:
① 通过父进程捕获
SIGCHLD
信号,在信号处理函数调用
wait()
函数或者
waitpid()
函数处理僵尸进程。
#include <sys/wait.h>
#include <sys/types.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
static void sig_cld(int); //信号处理函数
int main(){
pid_t pid;
if (signal(SIGCLD, sig_cld) == SIG_ERR)
perror("signal error");
if ((pid = fork()) < 0) {
perror("fork error");
} else if (pid == 0) {
sleep(2);
_exit(0); //子进程终结
}
pause(); //pause:使进程挂起,直到接收到一个信号并从信号处理函数中返回才结束挂起状态
exit(0);
}
static void sig_cld(int signo) {
pid_t pid;
int status;
if (signal(SIGCLD, sig_cld) == SIG_ERR)
perror("signal error");
if ((pid = wait(&status)) < 0) //阻塞等待子进程,获得其进程号和终止状态
perror("wait error");
printf("pid = %d\n", pid);
}
② fork()
两次。将子进程变成孤儿进程,从而其父进程变成init
进程,通过init
进程处理僵尸进程。如下图所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
int main(){
pid_t pid;
pid = fork(); //创建第一个子进程
if (pid < 0){
perror("fork error:");
exit(1);
}
else if (pid == 0)//第一个子进程{
printf("I am the first child process.pid:%d\tppid:%d\n",getpid(),getppid()); //子进程再创建子进程
pid = fork(); //孙子进程
if (pid < 0){
perror("fork error:");
exit(1);
}
else if (pid >0){ //第一个子进程退出,此时孙子进程被init进程收养
printf("first procee is exited.\n");
exit(0);
}
sleep(3);
printf("I am the second child process.pid: %d\tppid:%d\n",getpid(),getppid());
exit(0);
}
if (waitpid(pid, NULL, 0) != pid){ //父进程处理第一个子进程退出
perror("waitepid error:");
exit(1);
}
exit(0);
return 0;
}
1.4 进程组与会话
进程组是由父进程与其子进程建立的一系列进程集合。其特点如下:
① 在linux中,每一个进程都属于一个进程组。
② 当一个进程被创建时,它默认是其父进程所在组的成员,且一个进程组的ID(pgid)
=这个组的第一个成员的进程ID(pid)
。在linux中,可以通过命令ps -j
来查看进程组。
③ 只要进程组中有一个进程存在,则该进程组存在。进程组与组长进程是否终止无关。
会话是一个或多个进程组的集合,一般开始于用户登录,终止于用户退出,在此期间的所有进程都属于这个会话期。
在linux中,若一个进程不是进程组的组长,则可以调用
setsid()
函数来创建一个新会话。此时,该进程就会成为新会话的会话首进程,同时也是新进程组的组长进程,且该进程没有控制终端。如果在调用
setsid()
之前,该进程有一个控制终端,那么与控制终端的联系会被切断。
1.5 守护进程
守护进程就是通常讲Damon进程,是linux后台执行的一种服务进程,其特点如下:
① 守护进程必须独立于控制终端、与其运行前的环境隔离开来。这些环境包括未关闭的 文件描述符,控制终端,会话和进程组,工作目录以及文件创建掩模等
② 守护进程不会随终端关闭而停止,直到接受停止信息才会结束。
守护进程的创建流程如下图所示:
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h> //close() 头文件
#include<stdlib.h>
#include <sys/stat.h>
#include<fcntl.h>
#include <signal.h>
void myDamon()
{
//1.创建一个子进程,删除父进程,让子进程在后台运行
pid_t pid = fork();
if(pid < 0)
perror("fork");
else if(pid > 0)
exit(0);
//2.子进程调用setsid,创建一个新的会话,
//脱离控制终端,登录会话和进程组-setsid()
pid_t ret = setsid();
if(ret < 0)
perror("setsid");
//3.禁止进程重新打开控制终端
pid = fork();
if(pid < 0)
perror("fork");
else if(pid > 0)
exit(0);
//4.关闭打开的文件描述符
for(int i=0;i< NOFILE;++i)//关闭打开的文件描述符
close(i);
//5.修改工作目录为“/”
if(chdir("/") < 0)
perror("chdir");
//6.屏蔽umask,重设文件创建掩码
umask(0);
//7.忽略掉SIGCHLD信号,忽略SIGHUP信号
signal(SIGCHLD,SIG_IGN);
}
int main(){
myDaemon(); //调用该函数之后就会使得main进程变为守护进程
while(1){
//在守护进程中要运行的程序
}
}
1.6 Linux 惊群效应
惊群效应是指在多进程/多线程等待同一资源时,当某一资源可用时,多个进程/线程会被唤醒,竞争资源。
Q1. 惊群效应会产生什么问题 ?
① 多个进程/线程被唤醒,系统对用户进程/线程做无效的调度及上下文切换,影响系统性能。
② 为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,增大了系统开销。
💗 1.6.1 socket编程 - accept 惊群 (linux2.6版本之后已解决)
当主进程创建了socket,bind,listen后,通过fork创建多个子进程,每个子进程都循环处理listen创建的listen_id,因此都阻塞在accept处,当连接到来时,所有accept的进程都被唤醒,但只有一个进程可以成功连接,其余的连接失败,重新休眠。
💗 1.6.2 epoll惊群
① epoll_create
在fork
子进程之前:
如果epoll_create
调用在fork
子进程之前,那么epoll_create
创建的epoll_id
会被所有子进程继承,当子进程调用epoll_ctl
,将新建的连接描述符connfd
加入到epoll_id
,因为epoll_id
在fork
之前创建,因此所有子进程共享一个epoll_id
描述符,任何一个进程(父进程或子进程)向epoll
监控文件添加、修改和删除文件描述符时,都会影响到其它进程的epoll_wait
。当connfd
描述符上接收到客户端信息时,内核也无法保证每次都是唤醒同一个进程/线程,来处理这个连接描述符connfd
上的读写信息,最终导致连接处理错误。因此,应该避免epoll_create
在fork
子进程之前。
②
epoll_create
在
fork
子进程之后:
如果
epoll_create
在
fork
子进程之后,则每个进程都有自己的
epoll
监控文件,当某个进程将新建连接的描述符
connfd
加入到本进程的
epoll_id
中统一监控,不会影响其它进程的
epoll_wait
,但是为了实现并发监听,所有的子进程都会调用
epoll_ctl
,将监听描述符
connfd
加入监听描述符中。如果有新的客户端请求接入,监听描述符出现
POLLIN
事件,此时内核会唤醒所有的进程。
解决此惊群问题思路就是:通过互斥锁对每个进程从
epoll_wait
到
accept
之间的处理通过互斥量保护。
lock()
epoll_wait(...);
accept(...);
unlock(...);
💗 1.6.3 线程惊群
在线程的条件变量中,如果调用pthread_cond_broadcast
,且没有加锁,则会导致线程惊群。
1.7 Linux 进程间通信
💗 1.7.1 Linux System V IPC资源
System V IPC
是Linux 中IPC的基础,其包括System V信号量,消息队列,共享内存三种进程通信方式,其关系如下图所示:
1. System V 信号量
2. 消息队列
💗 1.7.2 Linux 管道通信
Linux 管道通信分为匿名管道通信pipe
和命名管道FIFO
两种方式。在Linux中,pipe
与FIFO
都是基于pipefs
特殊文件系统来实现的。
1. 匿名管道 pipe
Linux 管道通信是通过pipe()
系统调用实现的,其创建过程如下图所示:
#include<stdio.h> //标准输入输出
#include<stdlib.h> //标准库头文件
#include<unistd.h> //提供对POSIX操作系统API的访问功能
#include<fcntl.h> //unix标准中通用的头文件
#include<string.h>
int main(){
int fds[2];
if(pipe(fds)<0){
perror("make pipe");
exit(1);
}
char buf[1024];
printf("please enter:");
fflush(stdout); // 强制马上输出,避免错误
ssize_t s=read(0,buf,sizeof(buf)-1);
if(s>0)
buf[s]=0;
pid_t pid=fork();
if(pid==0){
close(fds[0]); //关闭子进程的读端
sleep(1);
write(fds[1],buf,strlen(buf)); //阻塞等待输入
}else{
close(fds[1]); //关闭父进程的写端
char readbuf[1024];
ssize_t rlen=read(fds[0],readbuf,sizeof(readbuf)-1);
if(rlen>0){
readbuf[rlen-1]=0;
printf("client read :%s\r\n",readbuf);
}
}
}
2. FIFO
FIFO是双向通信管道,当FIFO被创建后,就可以使用普通的open()
,read()
,write()
,close()
来操作和访问FIFO
。
2、Linux 多线程编程
2.1 多线程运行环境
💗 2.1.1 线程安全 -可重入函数
当进程正在执行
malloc()
动态内存分配时,信号中断产生从而转入到信号处理程序,但当信号处理程序中也用到了
malloc()
函数时,因为
malloc()
通常维护一个所有已分配内存链表,当信号中断发生时,进程可能正在修改链表指针,这时在信号处理程序中将又一次修改链表。这会导致程序或函数执行错误。
一个函数被多个并发线程反复调用时,其结果一直是正确的,则该函数为线程安全,称为可重入函数。一般来说,可重入函数一定是线程安全的,但线程安全的不一定是可重入函数。要确保函数的可重入,需要满足以下条件:
① 不在函数内部使用
static
或
global
变量。
② 不返回
static
或
global
数据,所有数据由函数的调用者提供。
③ 使用本地数据,或对
global
全局数据进行本地拷贝,保护全局数据。
④ 函数中不能调用不可重入函数。
💗 2.1.2 线程与进程
如果一个多线程程序的某个线程调用
fork()
,则新创建的子进程不会自动创建与父进程相同数量的线程。子进程只会拥有一个执行线程,该线程是调用
fork()
的那个线程的复制,并且子进程会继承父进程中的互斥锁状态。若互斥锁已经被其他线程锁住,则子进程再次加锁会导致死锁。
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#include<wait.h>
#include<stdlib.h>
pthread_mutex_t mutex;
void *fun(void *arg){
printf("children thread\r\n");
pthread_mutex_lock(&mutex);
sleep(3);
pthread_mutex_unlock(&mutex);
}
int main(){
pthread_t pid;
pthread_mutex_init(&mutex,NULL);
pthread_create(&pid,NULL,fun,NULL); //其他线程加锁
sleep(1);
int fid=fork();
if(fid==0){
printf("I am children pid\r\n");
pthread_mutex_lock(&mutex);
printf("can't run \r\n"); //在此产生死锁
pthread_mutex_unlock(&mutex);
exit(0);
}else if(fid>0){
wait(NULL);
}
pthread_join(pid,NULL);
pthread_mutex_destroy(&mutex);
}
3、Linux I/O编程
对Linux来说,一切都是文件,这对于I/O来说也是适用的,即I/O的编程和操作都是基于文件的。对I/O的操作就是对数据的操作。
3.1 I/O 基本介绍
💗 3.1.1 文件描述符(文件句柄)
前面说到,对I/O的操作都是基于文件的,那么就需要文件描述符用来表示进程正在访问的I/O,如当内核打开一个现有文件和创建一个新文件时,都返回一个文件描述符。当系统创建一个socket时会返回一个socket描述符。
💗 3.1.2 标准输入\输出\错误
当运行一个新程序时,shell都为其打开3个文件描述符:标准输入,标准输出和标准错误,这3个描述符都链接到终端。
3.2 基本I/O
💗 3.2.1 read / write
read 用于从标准输入中读取的单行,write 用于向标准输出中写入单行,是最常用的系统调用之一,其系统的实现过程如下:
3.3 I/O 复用
I/O复用可以使程序同时监听多个文件描述符,能够提高程序的性能。需要注意:I/O复用虽然能够同时监听多个文件描述符,但其本身是阻塞的(阻塞于复用阻塞时期),如果要实现并发,就必须使用多线程或多进程。在Linux中,I/O复用的系统调用主要有select,poll和epoll。
💗 3.3.1 select
select是通过对文件描述符集合fd_set
的监视来实现I/O的复用的,调用select后调用后会阻塞,直到有描述符发生改变,或者超时,函数返回。当有描述符发生改变时函数返回的是就绪描述符的个数,因此需要对文件描述符集合进行轮询遍历,找到发生改变的描述符,并进行后续操作。
select的一个缺点在于单个进程能够监视的文件描述符的数量也存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制。但是这样也会造成效率的降低。
// select 监测单个I/O
#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>
#include<string.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
int main(){
struct sockaddr_in serveraddr;
memset(&serveraddr,0,sizeof(serveraddr));
serveraddr.sin_family=AF_INET;
serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);
serveraddr.sin_port=htons(6000);
int sockfd;
if((sockfd=socket(AF_INET,SOCK_STREAM,0))==-1)
exit(1);
if(bind(sockfd,(struct sockaddr*)&serveraddr,sizeof(serveraddr))==-1)
exit(1);
if((listen(sockfd,10))==-1)
exit(1);
struct sockaddr_in clientaddr;
socklen_t len=sizeof(clientaddr);
int connfd=accept(sockfd,(struct sockaddr *)&clientaddr,&len);
if(connfd<0)
close(sockfd);
char buff[1024];
fd_set fd;
FD_ZERO(&fd);
while(1){
memset(buff,0,sizeof(buff));
FD_SET(connfd,&fd);
int ret=select(connfd+1,&fd,NULL,NULL,NULL);
if(ret<0)
break;
if(FD_ISSET(connfd,&fd)){
ret=recv(connfd,buff,sizeof(buff)-1,0);
if(ret<=0)
break;
printf("Recvive : %s",buff);
}
}
close(connfd);
close(sockfd);
}
💗 3.2.2 poll
poll在本质上和select相同,也需要对文件描述符集合进行轮询遍历,找到发生改变的描述符,并进行后续操作。但是poll没有最大文件描述符数量的限制,因为poll是基于链表的,而select是基于描述符数组的。
💗 3.2.3 epoll
epoll与select和poll不同。epoll会把用户注册的文件描述符放到内核中的一个事件表,从而无需向select和poll那样每次调用都要重复传入文件描述符集或事件集。epoll仅需要一个额外的文件描述符,用来唯一标识内核中的事件表。epoll从本质上来说是一种通过空间来换取时间的策略。
epoll通常有两种工作模式LT(电平触发)模式和ET(边沿触发)模式。
① LT模式: LT模式是默认的工作模式,在此模式下,epoll相当于一个效率高的poll。当epoll_wait
上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件,下次调用epoll_wait
时,还会向应用程序通告此事件。
② ET模式: ET模式是epoll的高效工作模式,当epoll_wait
上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,后续不会再通告此事件。ET模式降低了同一个epoll事件被重复触发的次数。注意:每个使用ET模式的文件描述符都应该是非阻塞的,如果是阻塞的,则读或写操作会因为没有后续事件而一直处于阻塞状态。
Q1. LT模式和ET模式的应用场所 ?
LT模式比较慢,但是比较安全,而ET模式比较快,但是有可能造成事件的丢失,这就可能让程序永远阻塞。LT为了担责,降低了效率,而ET为了效率将责任推给了用户
EPOLLONESHOT
事件:
在并行程序中,当一个线程读取完某个socket上的数据后开始处理数据,在数据处理过程中,该socket又出现新的数据可读,此时就会调用另一个线程来读取新的数据,这样就会出现两个线程同时操作一个线程的情况。为了实现任意时刻一个socket都只被一个线程处理,可以使用epoll的EPOLLONESHOT
事件
💗 3.2.4 select、poll、epoll区别
3.4 I/O 转发与重定向
💗 3.4.1 splice - 描述符的移动
splice
用于在两个文件描述符之间移动数据, 也是零拷贝。
#include <fcntl.h>
//描述符从fd_in 移动到 fd_out
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
其中,fd_in
或者 fd_out
必须有至少一个是 pipe,其功能是从 fd_in
消费数据并复制到 fd_out
中,
splice
的主要应用场景:
① 数据的转发
从一个 非 pipe 的fd(通常是socket)splice 到一个预先创建的 pipe,然后从这个pipe再 splice
出去到别的socket上,实现数据的转发。(一个描述符 => pipe => 另一个描述符)
// ...套接字设置部分省略...
int connfd=accept(sockfd,(struct sockaddr *)&clientaddr,&len);
if(connfd<0)
close(sockfd);
else{
int pipefd[2];
int ret=pipe(pipefd); //创建pipe
//将connfd描述符移动到管道pipe中,connfd=>pipe
ret=splice(connfd,NULL,pipefd[1],NULL,32768,SPLICE_F_MORE | SPLICE_F_MOVE);
if(ret==-1){
close(connfd);
exit(1);
}
//将管道pipe输出移动到连接描述符connfd中,pipe=>connfd,实现数据的转发
ret=splice(pipefd[0],NULL,connfd,NULL,32768,SPLICE_F_MORE |SPLICE_F_MOVE);
if(ret==-1){
close(connfd);
exit(1);
}
}
4、Linux 信号
信号是一种软件中断,信号提供了一种处理异步时间的方法。在Linux中,每个信号都对应一个名字,都以SIG
开头,且信号名都定义为正整数常量,不存在编号为0的信号。
在Linux中,产生信号的条件有5种:
① 当用户输入某些终端键时,引发终端产生信号(如:Ctrl+C => SIGINT)
② 硬件异常产生信号(如:执行一个无效内存引用的进程 => SIGSEGV)
③ 进程调用kill(2)函数将任意信号发送给另一个进程或进程组。
④ 用户调用kill(1)将信号发送给其他进程,常用 此命令终止一个失控的后台进程。
⑤ 检测到某种软件条件发生,并将其通知有关进程时产生信号。(如:定时器超时 => SIGALRM ,网络传来带外数据 => SIGURG)
当信号产生后,通常通过3种方式对信号进行处理:
① 忽略此信号。但SIGKILL
和SIGSTOP
不能忽略。
② 捕捉信号。要内核在某种信号发生时,调用一个用户函数,对信号进行处理。注意:不能捕捉SIGKILL和SIGSTOP信号。
③ 执行系统默认动作。对大部分信号的系统默认动作是终止该进程。
Linux 信号设置与执行的流程如下:
//以定时器alarm信号建立epoll服务器
/* 内核 => [==========socketpair=========] => epoll监听
| |
m_pipefd[1] m_pipefd[0] */
#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>
#include<string.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include <sys/epoll.h>
#include <errno.h>
#include<signal.h>
int pipefd[2];
//在信号处理函数中向socketpair中发送消息
static void timeout_handler(int sig){
printf("signal\r\n");
char buf[3]="OK";
int ret=send(pipefd[1],(char *)&sig,1,0);
}
void init_sigaction(int sig){
struct sigaction act;
act.sa_handler=timeout_handler;
act.sa_flags |=SA_RESTART;
sigfillset(&act.sa_mask);
if((sigaction(sig,&act,NULL))==-1){ //检查并修改信号关联动作
printf("%s","Signal create error");
exit(1);
}
}
int main(){
struct sockaddr_in serveraddr;
memset(&serveraddr,0,sizeof(serveraddr));
serveraddr.sin_family=AF_INET;
serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);
serveraddr.sin_port=htons(6000);
int sockfd;
if((sockfd=socket(AF_INET,SOCK_STREAM,0))==-1)
exit(1);
if(bind(sockfd,(struct sockaddr*)&serveraddr,sizeof(serveraddr))==-1)
exit(1);
if((listen(sockfd,10))==-1)
exit(1);
//epoll事件相关
int epollfd=epoll_create(100);
if(epollfd<0){
printf("%s, errno is: %d","Epoll create error",errno);
exit(1);
}
struct epoll_event event;
struct epoll_event *pevents;
//主线程往epoll内核注册socketfd读就绪事件
event.events=EPOLLIN;
event.data.fd=sockfd;
epoll_ctl(epollfd,EPOLL_CTL_ADD,sockfd,&event);
pevents=(struct epoll_event*)malloc(sizeof(struct epoll_event)*100);
if(socketpair(PF_UNIX,SOCK_STREAM,0,pipefd)==-1){
exit(1);
}
int old_opt=fcntl(pipefd[1],F_GETFL,0); //获取文件描述符
fcntl(pipefd[1],F_SETFL,old_opt|O_NONBLOCK); //设置文件描述符为非阻塞标志
//主线程往epoll内核注册pipefd读就绪事件
event.events=EPOLLIN |EPOLLET;
event.data.fd=pipefd[0];
epoll_ctl(epollfd,EPOLL_CTL_ADD,pipefd[0],&event);
init_sigaction(SIGALRM); //安装信号处理函数
alarm(5); //启动定时器信号,启动后只会定时一次
while(1){
int num=epoll_wait(epollfd,pevents,100,-1);
if(num<0 && errno!=EINTR){
/*安装信号处理函数后,如果进程收到了信号,会首先调用信号处理函数,信号处理
完成后返回时,epoll_wait返回-1,错误码置位EINTR,因此必须忽略此错误*/
printf("%s","Epoll Wait error");
exit(1);
}
for(int i=0;i<num;i++){ //如果socket有数据可读,则将socket可读时间放入请求队列
int socketfd=pevents[i].data.fd;
if(socketfd==sockfd){
struct sockaddr_in clientaddr;
socklen_t len=sizeof(clientaddr);
int connfd=accept(sockfd,(struct sockaddr *)&clientaddr,&len);
if(connfd<0)
close(sockfd);
}else if((socketfd==pipefd[0]) &&(pevents[i].events &EPOLLIN)){ //通过epoll接收定时消息
printf("定时到达\r\n");
//处理定时操作
}else if(pevents[i].events &EPOLLIN)
//处理读数据操作
}
}
}
5、Linux 定时器
在linux中包含三种定时方法和三个高效的管理定时器的容器:有序双向时间链表,时间轮和时间堆。
5.1 定时方法
linux中有三种定时方法:
① socket选项SO_RCVTIMEO(接收超时)和SO_SNDTIMEO(发送超时)
② SIGALRM信号
③ I/O复用系统调用的超时参数。
💗 5.1.1 socket选项SO_RCVTIMEO和SO_SNDTIMEO
这两个选项仅对数据接收和发送相关的socket专用系统调用有效,包括send,sendmsg,recv,recvmsg,accept,connect。当超时后对应的错误号为EINPROGRESS
,可以通过判断该错误号来处理定时任务。
······
int time; //超时时间
struct timeval timeout;
timeout.tv_sec=time;
timeut.tv_usec=0;
socklen_t len=sizeof(timeout);
setsockopt(sockfd,SOL_SOCKET,SO_SNDTIMEO,&timeout,len); //设置socket时间超时
······
if(errno==EINPROGRESS){ //通过错误号来进行判断
//超时处理程序
}
💗 5.1.2 SIGALRM 信号
SIGALRM
是在定时器终止时发送给进程的信号,在使用时需要先安装信号处理函数,然后调用alarm()函数启动定时器,待时间结束,内核发出SIGALRM
信号。若没有提前安装信号处理函数,则出现SIGALRM后,默认会结束应用进程。
💗 5.1.3 I/O复用系统调用的超时参数。
5.2 定时容器
💗 5.2.1 有序双向时间链表定时器
该定时器利用双向链表,并在插入,删除时保持定时器的有序。基于有序链表的定时器存在一个问题:插入操作的效率会随着定时器个数增加而降低。
💗 5.2.2 时间轮定时器
在时间轮中包含多个链表和一个"旋转"指针,旋转指针指向一个槽,并以恒定的速度顺时针旋转,每转动一步就指向下一个槽。时间轮中会将定时器通过哈希表散列到不同的中,这样每一条链表中的定时器数目远少于有序链表上的定时器数目。
💗 5.2.3 时间堆定时器
前两种, 有序双向定时器和时间轮定时器都是以固定频率来定时,并需要遍历容器,检测到期的定时器。然后执行到期定时器上的回调函数。
基于时间堆定时器将容器中超时时间最小的一个定时器的超时值作为定时时间,这样当定时信号到来时,超时时间最小的定时器必会到期。时间堆基于优先队列的原理和思想。
6、Linux 网络编程
6.1 Socket API
💗 6.1.1 主机字节序和网络字节序
字节在内存中的排列顺序会影响其装载时的形成的整数的值。字节序分为大端字节序和小端字节序。
① 大端字节序:一个整数的高位字节(23~31 bit) 存储在内存的低地址,低位字节(0 ~ 7 bit) 存储在内存的高地址处。在网络中传送的都是大端字节序数据,又称网络字节序。
② 小端字节序:一个整数的高位字节(23~31 bit) 存储在内存的高地址,低位字节(0 ~ 7 bit) 存储在内存的低地址处。现在PC采用的是小端字节序,又称主机字节序。
为了解决两个主机的字节序不同,因此发送端总要将发送的数据转化为大端字节序数据,然后再发送。接收端根据自身采用的字节序决定是否对数据进行转换。
#include<netinet/in.h>
//host -> network
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
// network -> host
unsigned long int ntohl(unsigned long int hostlong);
unsigned short int ntohs(unsigned short int hostshort);
💗 6.1.2 Socket 操作
① 通用socket 与专用socket
② IP地址转换函数
6.2 Socket 建立连接
通过Socket API建立服务器与客户端的通信机制,以TCP为例,其建立过程如下图所示:
#include<stdio.h>
#include<arpa/inet.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h> //close() 头文件
#include<string.h> //memset() 头文件
#define MAX_BUFF 1024
#define port 6000
int main(){
//1.定义地址族 sockaddr_in
struct sockaddr_in serveraddr;
memset(&serveraddr,0,sizeof(serveraddr));
serveraddr.sin_family=AF_INET;
serveraddr.sin_port=htons(port);
serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);
//2.创建Socket
int sockfd=socket(AF_INET,SOCK_STREAM,0);
//3.绑定Socket与地址族
if(bind(sockfd,(struct sockaddr*)&serveraddr,sizeof(serveraddr))==-1){
printf(" -- Error: Socket bind error\n");
}
//4. 监听Socket
if((listen(sockfd,10))==-1){
printf(" -- Error: Socket listen error\n");
}
//5. 接收Socket,本质是从listen中取出ESTABLISH状态的连接
struct sockaddr_in cliaddr;
socklen_t len=sizeof(cliaddr);
int connfd=accept(sockfd,(struct sockaddr*)&cliaddr,&len);
//6. 读取数据
char buffer[MAX_BUFF];
memset(buffer,0,sizeof(buffer));
int buffnum=recv(connfd,buffer,MAX_BUFF-1,0);
if(buffnum>0){
printf("%s\r\n",buffer);
}
//7. 关闭Socket
close(sockfd);
}
6.3 linux 服务器
在linux服务器中,其架构主要分为四个主要部分:I/O处理单元,逻辑单元,存储单元,请求队列。其框架如下图所示:
在上述的架构下,存在两种基本模型:C/S架构和B/S架构。但无论哪种模型都包含框架中的四个部分。
除了上述的服务器架构,linux服务器程序还必须处理三类事件:I/O事件,信号和定时事件。通过统一事件源,利用I/O复用来管理所有事件。
💗 6.3.1 I/O处理单元
I/O的操作分为两大模型:同步I/O模型和异步I/O模型。在I/O模型中,同步和异步区分的是内核向应用程序通知的是何种 I/O事件(就绪事件还是完成事件),以及由谁来完成I/O读写(应用程序还是内核)。
同步I/O是指:在I/O的读写操作,都是在I/O事件发生以后进行的,必须等待或者主动的去询问IO是否完成,完成后才能继续执行其他操作;
异步I/O是指:用户可以直接对I/O进行操作,其读写操作总是立刻返回,不需要等待。在异步I/O中所有都是非阻塞I/O。
① 同步I/O - 阻塞I/O:
在默认情况下,所有的socket接口都是阻塞的,阻塞I/O可能会因为无法立刻完成而被操作系统挂起,直到等待的事件发生。
② 同步I/O - 非阻塞I/O:
非阻塞I/O一般通过轮询查看I/O的方式实现,会耗费大量的CPU时间。如果事件没有立刻发生,则内核立即返回WOULDBLOCK
错误。
③ 同步I/O - 多路复用I/O:
多路复用I/O是在阻塞I/O上的改进,通常使用select
,epoll
实现。多路I/O复用会阻塞于I/O复用的系统调用,如epoll_wait()
,但对单个I/O本身的操作是非阻塞的。多路复用I/O与阻塞I/O的不同在于,多路复用I/O可以同时阻塞多个I/O程序。多路复用I/O的优点就在于等待多个描述符就绪,可以同时处理大量的客户端连接,但并不会提高事件的处理效率。
④ 同步I/O - 信号驱动I/O:
信号驱动I/O允许Socket接口进行信号驱动,并包含信号处理函数,进程继续运行并不阻塞。当事件发生时,进程会收到SIGIO
信号,从而在信号处理函数中调用I/O操作处理事件。信号驱动I/O不会阻塞,其免去了多路复用I/O和非阻塞I/O的轮询。
⑤ 异步I/O:
异步过程中进程触发IO操作以后,直接返回,I/O交给内核来处理,完成后内核通知进程I/O完成。异步I/O与信号驱动I/O的主要区别是:信号驱动I/O是内核通知何时启动一个I/O操作,而异步I/O是内核通知何时I/O操作完成,信号在操作完成时才产生。
💗 6.3.2 事件处理模式 - 请求队列
由于在I/O处理单元中,已经捕获了客户端的请求,就要将请求传送到逻辑处理单元。根据请求在逻辑单元中对事件进行处理。事件处理模式分为两种模式:Reactor
模式和Proactor
模式
① Reactor(反应堆)模式
Reactor
模式要求I/O处理单元只负责监听文件描述符中是否有事件发生,若有,就立即将该事件通知逻辑单元。常见的Reactor模式有:epoll+pthread poll
② Proactor模式
Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。Proactor模式是异步I/O。
Reactor与Proactor模式的区别:
Reactor是在事件发生时就通知事先注册的事件,读写由事件处理函数完成;Proactor是在事件发生时进行异步I/O,数据的读写是由操作系统来完成,待IO完成事件分离器才调度工作线程来处理。
💗 6.3.3 并发模式 - 逻辑处理单元 - 工作线程多进程、多线程
服务器的并发是为了让程序同时执行多个任务,提高CPU的利用率。并发的编程主要通过多线程和多进程来实现。在并发的设计模式上,分为两种:半同步/半反应堆模式和领导者/追随者模式。
① 半同步/半反应堆模式
同步线程用于处理客户逻辑,异步线程用于处理I/O事件。异步线程监听到客户请求后,将请求插入请求队列,请求队列通知某个工作线程读取并处理请求。
② 领导者/追随者模式
该模式是多个工作线程轮流获得事件源集合,轮流监听,分发并处理事件。 领导者/追随者模式包含的组件有:句柄集,线程集,事件处理器。
💗 6.3.4 有效状态机 - 逻辑处理方式 - 用于不同状态的处理和分析
有限状态机FSM,表示有限个状态及在这些状态之间的转移和动作等行为的模型,在服务器中,服务器可以根据不同状态或者消息类型进行相应的处理逻辑,使得程序逻辑清晰易懂。
💗 6.3.5 提高服务器性能
一个服务器,除了上述的4个主要部分,在 池, 数据复制, 上下文切换和 锁,4个方面也能提高服务器性能。
① 池
“池”是服务器中以空间换取时间的方式。 池是一组资源的集合,这组资源在服务器启动之前就创建好并初始化,“池”是一种静态资源分配,可以减少动态分配资源时产生的耗时。常见的"池"包括:内存池(socket的接收和发送缓存),进程池(并发模式),线程池(并发模式),连接池(服务器集群内部永久连接)。
Q1 进程/线程池任务如何选择 ?
当有新任务到来时,主进(线)程有两种方式来选择子进(线)程进行任务处理:
● 主进(线)程使用某种算法来主动选择子进(线)程,常用的算法有:随机算法,
Round Robin(轮流选取)
算法。
● 主进(线)程和所有子进(线)程通过一个共享的工作队列来同步,子进(线)程都睡眠在工作队列中,当有新任务时,主进(线)程将任务添加到该工作队列中,并唤醒子进(线)程,执行任务。
② 数据复制
服务器应该避免不必要的数据复制,特别是用户代码与内核之间的数据复制。除此之外,用户代码内部的数据复制应该尽量避免。
③ 上下文切换
上下文切换是进程切换或线程切换导致的系统开销。 在多线程服务器中,不同的线程可以同时运行在不同的CPU中,当线程的数量不大于CPU的数目时,上下文切换就不是问题。
④ 锁
共享资源的加锁保护是影响服务器效率的重要因素。 若服务器有更好的方案时,应该避免使用锁。若必须用锁,则减少锁的粒度,如对共享内存的读操作不需要加锁,只对其中某个进程需要写操作时才调用锁。
7、Linux 内存管理
7.1 Linux 进程内存管理
7.2 进程堆管理 -从brk(),mmap()到malloc和free
在每个进程中,都有一个独立的堆段(Heap
)空间,在标准C库中,提供了malloc
函数,free
函数来分配和释放内存,而这两个函数在底层调用了brk
,mmap
,munmap
系统调用实现的。首先介绍brk
和mmap
,然后介绍malloc
和free
底层原理。
💗 7.2.1 brk()和mmap()
1. brk()
brk()
函数的作用是将数据段(.data)的最高地址指针_edata
往高地址移动,因此可以通过修改brk()来调整堆空间。
2. mmap
Q1. 什么是mmap内存映射 ?
mmap
内存映射主要有两个作用:
① 通过将一个虚拟内存区域(堆和栈中间,称为文件映射区域的地方)与一个磁盘上的对象(文件)关联起来,从而可以在进程的虚拟内存空间中分配实际的文件磁盘地址空间。实现映射后,进程就可以采用指针的方式读写这段内存,系统会自动回写脏页面到磁盘中,完成对文件的操作,而不必在调用write
和read
系统调用函数。
② 将内核整个地址空间视为诸如文件之类的一组不同对象的映射,将页面映射到进程的地址空间中,当进程访问页面时产生一个缺页中断,内核将页面读入内存并且更新页表指向该页面。
Q2. 为什么mmap的文件读写传统的系统调用(read,write)快 (性能分析) ?
使用mmap
进行文件的读写的流程如下如图所示:
通过与系统调用(read/write
)方法相比,可以发现,由于mmap
方式使进程虚拟内存与地址空间形成映射,因此文件的读写时减少了在用户空间与内核空间进行数据的copy,从而提高效率。
在写小数据时,mmap
会比write
调用快。但在写大数据时,mmap
比write
慢。在读数据时,mmap
要比read
快。
Q3. mmap的优点和缺点 ?
优点:
① 对文件的读取操作跨过了页缓存,减少了数据的拷贝次数,用内存读写取代I/O读写,提高了文件读取效率。
② 实现了用户空间与内核空间的高效交互。
③ 提供进程间的共享内存及相互通信的方式。
④ 可用于实现高效的大规模数据传输,解决内存空间不足时,硬盘操作产生大量文件I/O问题。
缺点:
① 当文件大小<4096
字节时,会造成内存空间浪费。由于内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位,因此实际映射到虚拟内存区域的大小是4096字节,其他空间用0填充,造成内存空间浪费。
② 对变长文件不合适。因为mmap映射内存时确定了具体的内存范围。
③ 在随机写很多的情况下,会触发大量随机I/O,mmap方式在效率上不一定会比带缓冲区的一般写快。
//使用共享映射mmap修改文件
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/mman.h>
#include<sys/stat.h>
int main(){
int fd=open("user.dat",O_RDWR); //打开文件描述符
if(fd==-1){
printf("open error\r\n");
exit(-1);
}
char *p;
printf("open\r\n");
struct stat st;
if(fstat(fd,&st)==-1){ //获得文件描述符状态
exit(1);
}
//将描述符的虚拟空间与地址空间进行映射,随后可以利用 指针 对文件进行读写
p=(char *)mmap(NULL,st.st_size,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
if(p==NULL || p==(void *)-1){
exit(1);
}
close(fd); //此时关闭文件描述符仍然可以对文件进行读写
printf("%s\r\n",p);
p[6]='&'; //将脏页面回写至磁盘
if(msync(p,st.st_size,MS_SYNC)<0){
printf("msync\r\n");
}
printf("%s",p);
munmap(p,st.st_size);
}
//通过mmap实现父子进程间通信
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/mman.h>
#include<sys/stat.h>
int main(){
char *p_map;
p_map=(char *)mmap(NULL,1024,PROT_READ | PROT_WRITE,MAP_SHARED|MAP_ANONYMOUS,-1,0);
if(fork()==0){
sleep(1);
printf("get p->c %s\r\n",p_map);
munmap(p_map,1024);
exit(0);
}
sprintf(p_map,":%s","Hello world");
sleep(2);
}
💗 7.2.2 malloc()底层内存分配方式
通过malloc
进行内存分配时会根据分配内存的大小采用不同的分配方式,malloc<128k
时使用brk
分配内存,malloc>128k
时使用mmap
分配内存。
1. malloc<128k 时的内存分配与释放
2. malloc>128k 时的内存分配与释放
当申请空间大于128K时,malloc()
就不调用brk()
函数,而是调用mmap()
函数,利用内存映射方式寻找内存空间。
💗 7.2.3 malloc(),free() 的实现方案
malloc()
在底层内存分配上采用brk()
和mmap()
方式进行,除此之外,malloc()还需要从堆空间中寻找到与申请的内存大小相匹配的内存空间。因此malloc()
函数的整体实现方案如下:
① malloc()
函数的实质是它有一个将可用的内存块连接为一个长长的列表的空闲链表。
② 调用 malloc()
函数时,它沿着连接表寻找一个大到足以满足用户请求所需要的内存块。 然后,将该内存块一分为二(一块的大小与用户申请的大小相等,另一块的大小就是剩下来的字节)。 接下来,将分配给用户的那块内存存储区域传给用户,并将剩下的那块(如果有的话)返回到连接表上。
③ 调用 free()
函数时,它将用户释放的内存块连接到空闲链表上。
④ 到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段, 那么空闲链表上可能没有可以满足用户要求的片段了。于是,malloc()
函数请求延时,并开始在空闲链表上检查各内存片段,对它们进行内存整理,将相邻的小空闲块合并成较大的内存块。
7.3 Linux 共享内存
💗 7.3.1 shm 共享内存
shm
内存映射是指,多个进程的地址空间都映射到同一块物理内存,这样多个进程都能看到这块物理内存,实现进程间通信,而且不需要数据的拷贝,所以速度最快。当系统断电后,其中的内存数据会全部自行销毁。
附录
附录1:Linux 常用系统命令
💗 1.1 linux性能分析工具 - top
top命令是Linux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况。
💗 1.2 linux调试工具 - gdb
gdb是GNU开源组织发布的一个强大的Linux下的程序调试工具。
gdb调试命令如下: