C++后台开发知识总结(四)操作系统/Linux 内核

相关:
C++后台开发知识总结(一)C++基础
C++后台开发知识总结(二)数据库
C++后台开发知识总结(三)计算机网络
C++后台开发知识总结(四)操作系统/Linux 内核

中断

为什么需要中断:提高CPU运行效率
中断是指CPU对系统发生的某个事件做出的一种反应,CPU暂停正在执行的程序,保存现场后自动去执行相应的处理程序,处理完该事件后再返回中断处继续执行原来的程序。

中断一般三类:
1.由CPU外部引起的,如I/O中断、时钟中断
2.来自CPU内部事件或程序执行中引起的中断,例如程序非法操作,地址越界、浮点溢出
3.在程序中使用了系统调用引起的。

中断处理机制
为保证系统实时性,中断服务程序必须足够简短,但实际应用中某些时候发生中断时必须处理大量的事物,这时候如果都在中断服务程序中完成,则会严重降低中断的实时性,基于这个原因,linux系统提出了一个概念:把中断服务程序分为两部分-顶半部-底半部 。

顶半部
中断处理程序,功能是”登记中断”,当一个中断发生时,它进行相应地硬件读写后就把中断例程的下半部挂到该设备的下半部执行队列中去。
特点:响应速度快 ,不可中断

request_irq用于实现中断的注册功能:
int request_irq(unsigned int irq, void (*handler)(int, void*, struct pt_regs *), unsigned long flags, const char *devname, void *dev_id)
返回0表示成功,或者返回一个错误码
功能:向内核注册一个中断服务函数,发生中断号为irq的中断的时候,会执行handle指针函数。
中断注册(参数)
1)unsigned int irq 中断号。
2)void (*handler)(int,void *,struct pt_regs *) 中断处理函数。
3)unsigned long flags 与中断管理有关的各种选项,如快速中断,共享中断
IRQF_DISABLED(SA_INTERRUPT) 如果设置该位,表示是一个“快速”中断处理程序,保证中断处理的原子性(不被打断),在服务该中断时,不会被其他类型的中断打断
IRQF_SHARED(SA_SHIRQ) 表明中断可以在设备间共享。共享中断就是将不同的设备挂到同一个中断信号线上。Linux对共享的支持主要是为PCI设备(如声卡、网卡、MODEM等)服务。
4)const char * devname 设备名
5)void *dev_id 共享中断时使用。
void free_irq(unsigned int irq, void *dev_id) 释放中断

底半部
中断处理的大部分工作都在底半部,它几乎做了中断处理程序的所有事情。
特点:处理与中断有相关性但是可以延后执行的任务,可中断,而且可以被新的中断打断
底半部机制主要有:tasklet、工作队列和软中断
软中断:
软中断作为下半部机制的代表,是随着SMP(share memory processor)的出现应运而生的,也是tasklet实现的基础。它的出现就是因为要满足上面所提出的上半部和下半部的区别,使得对时间不敏感的任务延后执行,产生后并不是马上可以执行,必须要等待内核的调度才能执行。而且可以在多个CPU上并行执行,使得总的系统效率可以更高。
tasklet:
由于软中断允许多个CPU同时操作,这就导致设计上的复杂度变高,如果某种应用并不需要在多个CPU上并行执行,那么软中断其实是没有必要的。因此诞生了弥补以上两个要求的tasklet。它具有以下特性:
a)一种特定类型的tasklet只能运行在一个CPU上,不能并行,只能串行执行。
b)多个不同类型的tasklet可以并行在多个CPU上。
c)软中断是静态分配的,在内核编译好之后,就不能改变。但tasklet就灵活许多,可以在运行时改变(比如添加模块时)。
如果不需要软中断的并行特性,tasklet就是最好的选择。
工作队列:
1、由内核线程去执行,换句话说总在进程上下文执行。
2、可以睡眠,阻塞。

信号

信号:对于 Linux来说,实际信号是软中断,用来通知进程发生了异步事件。一个进程收到一个信号类似与处理器收到一个中断请求。信号为 Linux 提供了一种处理异步事件的方法。比如,终端用户输入了 ctrl+c(会产生SIGINT信号,对该信号的默认反应就是进程终止) 来中断程序,会通过信号机制停止一个程序。

信号的名字和编号:
每个信号都有一个名字和编号,这些名字都以“SIG”开头,例如“SIGIO ”、“SIGCHLD”等等。
信号定义在signal.h头文件中,信号名都定义为正整数。
在这里插入图片描述
具体的信号名称可以使用kill -l来查看信号的名字以及序号,信号是从1开始编号的,不存在0号信号。kill对于信号0又特殊的应用。

信号的处理有三种方法,分别是:忽略、捕捉和默认动作
忽略信号,大多数信号可以使用这个方式来处理,但是有两种信号不能被忽略(分别是 SIGKILL和SIGSTOP)
捕捉信号:当该信号产生时,由内核来调用用户自定义的函数,以此来实现某种信号的处理。
系统默认动作,对于每个信号来说,系统都对应由默认的处理动作,当发生了该信号,系统会自动执行。不过,对系统来说,大部分的处理方式都比较粗暴,就是直接杀死该进程。
信号的使用:
常用的 kill 命令就是一个发送信号的工具,kill 9 PID来杀死进程。比如,我在后台运行了一个 top 工具,通过 ps 命令可以查看他的 PID(进程标志号),通过 kill 9 来发送了一个终止进程的信号来结束了 top 进程。
在这里插入图片描述
如果查看信号编号和名称,可以发现9对应的是 9) SIGKILL,正是杀死该进程的信号。而以下的执行过程实际也就是执行了9号信号的默认动作——杀死进程。

信号的种类
可靠信号与不可靠信号
不可靠信号:Linux的信号继承自早期的Unix信号,信号值小于SIGRTMIN(34)的信号都是不可靠信号。这就是”不可靠信号”的来源。它的主要问题是信号可能丢失。1-31 都是不可靠的。
可靠信号:34-64重新设计的一套信号集合,不会出现信号丢失,支持排队
实时信号与非实时信号
实时信号 : 就是可靠信号 ;非实时信号:不可靠信号

SIGCHLD信号:在一个进程终止或者停止时,将SIGCHLD信号发送给其父进程,按系统默认将忽略此信号,如果父进程希望被告知其子系统的这种状态,则应捕捉此信号。
子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程。
SIGCLD信号:
对于SIGCLD的早期处理方式如下:如果进程特地设置该信号的配置为SIG_IGN,则子进程状态信息会被丢弃,将不产生僵死进程。

进程与线程

进程是系统进行资源调度和分配的的基本单位,实现了操作系统的并发;
线程是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;
线程是操作系统可识别的最小执行和调度单位。每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。每个线程完成不同的任务,但是共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。

进程和线程的区别:
1、一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。
2、进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。(资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)
3、进程是资源分配的最小单位,线程是CPU调度的最小单位。
4、进程创建/撤消或切换的开销大于线程。由于在创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/o设备等。因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。类似地,在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只须保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。
5、通信:由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。
6、进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。
7、一个进程崩溃,不会对其他进程产生影响;而一个线程崩溃,会让同一进程内的其他线程也死掉。
8、进程适应于多核、多机分布;线程适用于多核。
游戏服务器应该为每个用户开辟一个进程。因为同一进程间的线程会相互影响,一个线程死掉会影响其他线程,从而导致进程崩溃。因此为了保证不同用户之间不会相互影响,应该为每个用户开辟一个进程

多线程和多进程的不同
进程是资源分配的最小单位,而线程时CPU调度的最小单位。多线程之间共享同一个进程的地址空间,线程间通信简单,同步复杂,线程创建、销毁和切换简单,速度快,占用内存少,适用于多核分布式系统,但是线程间会相互影响,一个线程意外终止会导致同一个进程的其他线程也终止,程序可靠性弱。而多进程间拥有各自独立的运行地址空间,进程间不会相互影响,程序可靠性强,但是进程创建、销毁和切换复杂,速度慢,占用内存多,进程间通信复杂,但是同步简单,适用于多核、多机分布。
多进程和多线程的使用场景:
多进程,适用于CPU密集型,多线程,适用于I/O密集型的工作场景。多进程适用于多机分布式场景中,易于多机扩展,多线程适用于单机多核分布式场景。

如何设计server,使得能够接收多个客户端的请求:多线程,线程池,io复用
线程产生的原因:
进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;但是其具有一些缺点:进程在同一时间只能干一件事,进程在执行的过程中如果阻塞,整个进程就会挂起,即使进程中有些工作不依赖于等待的资源,仍然不会执行。因此,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时空开销,提高并发性。和进程相比,线程的优势如下:
1、从资源上来讲,线程是一种非常”节俭”的多任务操作方式。在linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种”昂贵”的多任务工作方式。
2、从切换效率上来讲,运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间。据统计,一个进程的开销大约是一个线程开销的30倍左右。(
3、从通信机制上来讲,线程间方便的通信机制。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过进程间通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其他线程所用,这不仅快捷,而且方便。
除以上优点外,多线程程序作为一种多任务、并发的工作方式,还有如下优点:
1、使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。
2、改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序才会利于理解和修改。

进程间通信

进程用户空间是相互独立的,一般而言是不能相互访问的。但很多情况下进程间需要互相通信,来完成系统的某项功能。进程通过与内核及其它进程之间的互相通信来协调它们的行为。
进程通信的应用场景:
数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几兆字节之间。
共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
资源共享:多个进程之间共享同样的资源。为了作到这一点,需要内核提供锁和同步机制。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

进程间通信主要包括:管道、消息队列、信号量、信号、共享内存、以及套接字socket管道( pipe )
主要包括无名管道和命名管道
管道可用于具有亲缘关系的父子进程间的通信,有名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信
消息队列
是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标记。消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等特点。具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息;
特点:
1)消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
2)消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
3)消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
信号量( semophore )
是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步,而不用于存储进程间通信数据。
特点:
1)信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
2)信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。
3)每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。
4)支持信号量组。
信号( sinal )
是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
共享内存( shared memory )
它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等
特点:
1)共享内存是最快的一种IPC,因为进程是直接对内存进行存取
2)因为多个进程可以同时操作,所以需要进行同步
3)信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问
Linux内存共享有多种,如mmap()、Posix共享内存、System V 共享内存。
套接字( socket )
可用于不同主机之间的进程通信。

线程间通信

Posix实现的线程,需使用 -lpthread 库编译 :

1
$ g++ test.cpp -lpthread -o test

1、 临界区
通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;
确保在某一时刻只有一个线程能访问数据的简便办法。在任意时刻只允许一个线程对共享资源进行访问。假如有多个线程试图同时访问临界区,那么在有一个线程进入后其他任何试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程能够继续抢占,并以此达到用原子方式操作共享资源的目的。
2、 互斥量
为协调一起对一个共享资源的单独访问而设计的
对资源加锁能阻塞其他线程的访问

1
2
3
4
5
6
#include <pthread.h>
pthread_mutex_t sum_mutex; //互斥锁
pthread_mutex_init( &sum_mutex, NULL ); //对锁进行初始化
pthread_mutex_lock( &sum_mutex ); //加锁
pthread_mutex_unlock( &sum_mutex ); //释放锁,供其他线程使用
pthread_mutex_destroy( &sum_mutex ); //注销锁

3、信号量
它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目

1
2
3
4
5
6
pthread_mutex_t tasks_mutex; //互斥锁
pthread_cond_t tasks_cond; //条件信号量
pthread_cond_init( &tasks_cond, NULL ); //初始化条件信号量
pthread_cond_signal( &tasks_cond ); //signal:向hello1发送信号
pthread_cond_wait( &tasks_cond, &tasks_mutex ); //wait:等待信号量生效,接收到信号,向hello2发出信号,跳出wait,执行后续
pthread_cond_destroy( &tasks_cond ); //销毁条件变量

4、事件
用来通知线程有一些事件已发生,从而启动后继任务的开始

内核线程:内核线程需要系统内核支持,同时系统内核支持内核线程,内核线程只运行在内核态,不受用户态上下文的拖累,内核线程只能由系统内核管理,像普通进程一样被调度。内核线程的使用是廉价的,唯一使用的资源就是内核栈和上下文切换时保存寄存器的空间。
轻量级进程: LWP, Light-Weight Process也是一种用户线程,运行在用户态,是建立在系统内核上的,由系统内核支持的用户线程。它是内核线程的高度抽象,所以轻量级进程需要系统内核支持,同时系统内核支持内核线程。每个轻量级进程都与一个内核线程关联,轻量级进程与内核线程一对一关联。
用户级线程:完全建立在用户态空间的线程,用户线程的创建、调度、同步和销毁全部在用户态空间中完成,不需要系统内核支持,也不需要系统内核支持内核线程。
Linux使用的线程库采用的是一对一关联,一个用户线程对应一个轻量级进程,一个轻量级进程对应一个内核线程。

协程

协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
协程和线程区别
那和多线程比,协程最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

微内核与宏内核

宏内核:除了最基本的进程、线程管理、内存管理外,将文件系统,驱动,网络协议等等都集成在内核里面,例如linux内核。
优点:效率高。
缺点:稳定性差,开发过程中的bug经常会导致整个系统挂掉。

微内核:内核中只有最基本的调度、内存管理。驱动、文件系统等都是用户态的守护进程去实现的。
优点:稳定,驱动等的错误只会导致相应进程死掉,不会导致整个系统都崩溃
缺点:效率低。典型代表QNX,QNX的文件系统是跑在用户态的进程,称为resmgr的东西,是订阅发布机制,文件系统的错误只会导致这个守护进程挂掉。不过数据吞吐量就比较不乐观了。

用户态和内核态区别

用户态和内核态是操作系统的两种运行级别,两者最大的区别就是特权级不同。用户态拥有最低的特权级,内核态拥有较高的特权级。运行在用户态的程序不能直接访问操作系统内核数据结构和程序。内核态和用户态之间的转换方式主要包括:系统调用,异常和中断。

用户态到内核态的转化原理:
1、系统调用
这是用户进程主动要求切换到内核态的一种方式,用户进程通过系统调用申请操作系统提供的服务程序完成工作。
2、异常
当CPU在执行运行在用户态的程序时,发现了某些事件不可知的异常,这是会触发由当前运行进程切换到处理此异常的内核相关程序中,也就到了内核态,比如缺页异常。
3、外围设备的中断
当外围设备完成用户请求的操作之后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条将要执行的指令,转而去执行中断信号的处理程序,如果先执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了有用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
从出发方式看,可以在认为存在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一样的,没有任何区别,都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断处理机制基本上是一样的,用户态切换到内核态的步骤主要包括:
1、从当前进程的描述符中提取其内核栈的ss0及esp0信息。
2、使用ss0和esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈找到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令
3、将先前由中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。

线程需要保存哪些上下文,SP、PC、EAX这些寄存器是干嘛用的

线程在切换的过程中需要保存当前线程Id、线程状态、堆栈、寄存器状态等信息。
其中寄存器主要包括SP PC EAX等寄存器,其主要功能如下:
SP:堆栈指针,指向当前栈的栈顶地址
PC:程序计数器,存储下一条将要执行的指令
EAX:累加寄存器,用于加法乘法的缺省寄存器

实现多线程

Win:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <thread>

using namespace std;

void thread01() {
for (int i = 0; i < 5; i++) cout << "Thread 01 is working !" << endl;
}
void thread02() {
for (int i = 0; i < 5; i++) cout << "Thread 02 is working !" << endl;
}

int main()
{
thread task01(thread01);
thread task02(thread02);
task01.join();
task02.join();

for (int i = 0; i < 5; i++)
cout << "Main thread is working !" << endl;
return 0;
}

在这里插入图片描述
阻塞主流程:
两个子线程并行执行,join函数会阻塞主流程,所以子线程执行完成后才继续执行主线程。

从主流程中分离:
可以使用detach将子线程从主流程中分离,独立运行,不会阻塞主线程:

1
2
3
4
thread task01(thread01);
thread task02(thread02);
task01.detach();
task02.detach();

在这里插入图片描述
带参数线程:

1
2
3
void thread01(int n) {
for (int i = 0; i < n; i++) cout << "Thread 01 is working !" << endl;
}
1
thread task01(thread01,5);

多线程数据竞争:
使用线程互斥对象mutex保持数据同步。mutex类的使用需要包含头文件mutex。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <mutex>
mutex mu; //线程互斥对象

void thread01() {
while (totalNum > 0){
mu.lock(); //同步数据锁
cout << totalNum << endl;
totalNum--;
mu.unlock(); //解除锁定
}
}
void thread02() {
while (totalNum > 0){
mu.lock();
cout << totalNum << endl;
totalNum--;
mu.unlock();
}
}

Linux:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <pthread.h>
using namespace std;
#define NUM_THREADS 5
// 线程的运行函数,函数返回的是函数指针,便于后面作为参数
void* say_hello(void* args)
{
cout << "Hello Runoob!" << endl;
}
int main()
{
// 定义线程的 id 变量,多个变量使用数组
pthread_t tids[NUM_THREADS];
for(int i = 0; i < NUM_THREADS; ++i)
{
//参数依次是:创建的线程id,线程参数,调用的函数,传入的函数参数
int ret = pthread_create(&tids[i], NULL, say_hello, NULL);
if (ret != 0)
{
cout << "pthread_create error: error_code=" << ret << endl;
}
}
pthread_exit(NULL);
}

使用 -lpthread 库编译 :

1
$ g++ test.cpp -lpthread -o test.o

传递参数:

1
2
3
4
5
6
7
void *PrintHello(void *threadid)
{
// 对传入的参数进行强制类型转换,由无类型指针变为整形数指针,然后再读取
int tid = *((int*)threadid);
cout << "Hello Runoob! 线程 ID, " << tid << endl;
pthread_exit(NULL);
}
1
rc = pthread_create(&threads[i], NULL,PrintHello, (void *)&(indexes[i]));

通过结构传递多个参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <cstdlib>
#include <pthread.h>
using namespace std;
#define NUM_THREADS 5
struct thread_data{
int thread_id;
char *message;
};
void *PrintHello(void *threadarg)
{
struct thread_data *my_data;
my_data = (struct thread_data *) threadarg;
cout << "Thread ID : " << my_data->thread_id ;
cout << " Message : " << my_data->message << endl;
pthread_exit(NULL);
}
int main ()
{
pthread_t threads[NUM_THREADS];
struct thread_data td[NUM_THREADS];
int rc;
int i;
for( i=0; i < NUM_THREADS; i++ ){
cout <<"main() : creating thread, " << i << endl;
td[i].thread_id = i;
td[i].message = "This is message";
rc = pthread_create(&threads[i], NULL, PrintHello, (void *)&td[i]); //传入到参数必须强转为void*类型,即无类型指针
if (rc){
cout << "Error:unable to create thread," << rc << endl;
exit(-1);
}
}
pthread_exit(NULL);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
using namespace std;
#define NUM_THREADS 5
void *wait(void *t)
{
int i;
long tid;
tid = (long)t;
sleep(1);
cout << "Sleeping in thread " << endl;
cout << "Thread with id : " << tid << " ...exiting " << endl;
pthread_exit(NULL);
}
int main ()
{
int rc;
int i;
pthread_t threads[NUM_THREADS];
pthread_attr_t attr;
void *status;
// 初始化并设置线程为可连接的(joinable)
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
for( i=0; i < NUM_THREADS; i++ ){
cout << "main() : creating thread, " << i << endl;
rc = pthread_create(&threads[i], NULL, wait, (void *)i );
if (rc){
cout << "Error:unable to create thread," << rc << endl;
exit(-1);
}
}
// 删除属性,并等待其他线程
pthread_attr_destroy(&attr);
for( i=0; i < NUM_THREADS; i++ ){
rc = pthread_join(threads[i], &status);
if (rc){
cout << "Error:unable to join," << rc << endl;
exit(-1);
}
cout << "Main: completed thread id :" << i ;
cout << " exiting with status :" << status << endl;
}
cout << "Main: program exiting." << endl;
pthread_exit(NULL);
}

并发(concurrency)和并行(parallelism)

并发(concurrency):指宏观上看起来两个程序在同时运行,比如说在单核cpu上的多任务。但是从微观上看两个程序的指令是交织着运行的,你的指令之间穿插着我的指令,我的指令之间穿插着你的,在单个周期内只运行了一个指令。这种并发并不能提高计算机的性能,只能提高效率。
并行(parallelism):指严格物理意义上的同时运行,比如多核cpu,两个程序分别运行在两个核上,两者之间互不影响,单个周期内每个程序都运行了自己的指令,也就是运行了两条指令。这样说来并行的确提高了计算机的效率。所以现在的cpu都是往多核方面发展。

系统调用

系统调用提供了用户程序与操作系统之间的接口(即系统调用是用户程序和内核交互的接口)。用户程序只在用户态下运行,有时需要访问系统核心功能,这时通过系统调用接口使用系统调用。危险的指令被包装成系统调用,用户程序只能调用而无权自己运行那些危险的指令。
系统调用举例:
对文件进行写操作,open和write都是系统调用。
创建进程fork,vfork等都是系统调用。

Linux虚拟内存

为了防止不同进程同一时刻在物理内存中运行而对物理内存的争夺和践踏,采用了虚拟内存。
1 每个进程有独立的虚拟地址空间,进程访问的虚拟地址并不是真正的物理地址
2 虚拟地址可通过每个进程上页表与物理地址进行映射,获得真正物理地址
3 如果虚拟地址对应物理地址不在物理内存中,则产生缺页中断,真正分配物理地址,同时更新进程的页表;如果此时物理内存已耗尽,则根据内存替换算法淘汰部分页面至物理磁盘中。

虚拟内存技术使得不同进程在运行过程中,它所看到的是自己独自占有了当前系统的4G内存。所有进程共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。 事实上,在每个进程创建加载时,内核只是为进程“创建”了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射),等到运行到对应的程序时,才会通过缺页异常,来拷贝数据。还有进程运行过程中,要动态分配内存,比如malloc时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。

优点:
1.既然每个进程的内存空间都是一致而且固定的,所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际的内存地址,这是有独立内存空间的好处
2.当不同的进程使用同样的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码,不同的进程只需要把自己的虚拟内存映射过去就可以了,节省内存
3.在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以利用碎片

  1. 内存保护:每个进程运行在各自的虚拟内存地址空间,互相不能干扰对方。
    虚拟内存的代价:
  2. 虚存的管理需要建立很多数据结构,这些数据结构要占用额外的内存
  3. 虚拟地址到物理地址的转换,增加了指令的执行时间。
  4. 页面的换入换出需要磁盘I/O,这是很耗时的
  5. 如果一页中只有一部分数据,会浪费内存。

    操作系统中的页表寻址

    页式内存管理,内存分成固定长度的一个个页片。操作系统为每一个进程维护了一个从虚拟地址到物理地址的映射关系的数据结构,叫页表,页表的内容就是该进程的虚拟地址到物理地址的一个映射。页表中的每一项都记录了这个页的基地址。通过页表,由逻辑地址的高位部分先找到逻辑地址对应的页基地址,再由页基地址偏移一定长度就得到最后的物理地址,偏移的长度由逻辑地址的低位部分决定。一般情况下,这个过程都可以由硬件完成,所以效率还是比较高的。页式内存管理的优点就是比较灵活,内存管理以较小的页为单位,方便内存换入换出和扩充地址空间。

    缺页中断

    malloc()和mmap()等内存分配函数,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常。
    缺页中断:在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存时,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。
    缺页本身是一种中断,与一般的中断一样,需要经过4个处理步骤:
    1、保护CPU现场
    2、分析中断原因
    3、转入缺页中断处理程序进行处理
    4、恢复CPU现场,继续执行
    但是缺页中断是由于所要访问的页面不存在于内存时,由硬件所产生的一种特殊的中断,因此,与一般的中断存在区别:
    1、在指令执行期间产生和处理缺页中断信号
    2、一条指令在执行期间,可能产生多次缺页中断
    3、缺页中断返回是,执行产生中断的一条指令,而一般的中断返回是,执行下一条指令。

malloc 的内存都有相应的 free ,就不会出现内存泄露了吗

free 的内存:
1 malloc 使用 mmap 分配的内存 ( 大于 128k) , free 会调用 unmmap 系统调用马上还给 OS ,实现真正释放。
2 堆内的内存,只有释放堆顶的空间,同时堆顶总连续空闲空间大于 128k 才使用 sbrk(-SIZE) 回收内存,真正归还 OS 。
3 堆内的空闲空间,是不会归还给 OS 的。

狭义上的内存泄露是指 malloc 的内存,没有 free ,导致内存浪费,直到程序结束。而广义上的内存泄露就是进程使用内存量不断增加,或大大超出系统原设计的上限。free 了的堆空闲空间并不会马上归还 OS ,并且堆内的空洞(碎片)更是很难真正释放,除非空洞成为了新的堆顶。
因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
因此,当我们写程序时,不能完全依赖 glibc 的 malloc 和 free 的实现。更好方式是建立属于进程的内存池,即一次分配 (malloc) 大块内存,小内存从内存池中获得,当进程结束或该块内存不可用时,一次释放 (free) ,可大大减少碎片的产生。

操作系统中的结构体对齐,字节对齐

1、原因:
1)平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
2)性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
2、规则
1)数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,比较小的那个进行。
2)结构(或联合)的整体对齐规则:在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。
3)结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 字节对齐
struct SByte1
{
double d; // 长度8,偏移量为0;存放位置区间[0,7]
char j; // 长度1,偏移量为8;存放位置区间[8]
int a; // 长度4,偏移量12;存放位置区间[12,15]
};
sizeof(SByte1); // = 16

struct SByte2
{
char j; // 长度1,偏移量为0;存放位置区间[0,1]
double d; // 长度8,偏移量8;存放位置区间[8,15]
int a; // 长度4,偏移量16;存放位置区间[16,19]
};
sizeof(SByte2); // = 24,为了凑成8的倍数,填充20~23

//可以通过#pragma pack(n)来设定变量以n字节对齐方式,n=1,2,4,8,16
#pragma pack(push) //保存对齐状态
#pragma pack(4) //设定为4字节对齐
class CByte
{
char c; //长度1 < 4 按1对齐;偏移量为0;存放位置区间[0,1]
double d; //长度8 > 4 按4对齐;偏移量为4;存放位置区间[4,11]
int i; //长度4 = 4 按4对齐;偏移量为12;存放位置区间[12,15]
};
#pragma pack(pop) //恢复对齐状态
sizeof(CByte); // = 16

#pragma pack(2)
struct AA {
int a; //长度4 > 2 按2对齐;偏移量为0;存放位置区间[0,3]
char b; //长度1 < 2 按1对齐;偏移量为4;存放位置区间[4]
short c; //长度2 = 2 按2对齐;偏移量要提升到2的倍数6;存放位置区间[6,7]
char d; //长度1 < 2 按1对齐;偏移量为7;存放位置区间[8];共九个字节
};
#pragma pack()

软链接和硬链接区别

为了解决文件共享问题,Linux引入了软链接和硬链接。除了为Linux解决文件共享使用,还带来了隐藏文件路径、增加权限安全及节省存储等好处。
硬链接:若1个inode号对应多个文件名
软连接:若文件用户数据块中存放的内容是另一个文件的路径名指向
1,软链接可以理解成快捷方式。它和windows下的快捷方式的作用是一样的。
2,硬链接等于cp -p 加 同步更新。
对文件jys建立软链接和硬链接:
在这里插入图片描述
软链接:ln -s 源文件 目标文件
硬链接:ln 源文件 目标文件
区别:
软链接文件的大小和创建时间和源文件不同。硬链接文件和源文件的大小和创建时间一样。
删除源文件多软链接和硬链接的影响:查看软链接文件,查看的文件不存在。但是删除源文件,硬链接文件还可以查看。
i节点是文件和目录的唯一标识,删除了jys,只是删除了从920586到jys的映射关系,不影响它和jys.hard的映射关系。此图也解释了硬链接的同步更新,对源文件修改,操作系统只认i节点,于是操作系统就将修改内容写进所有i节点相同名字不同的文件。
在这里插入图片描述

什么是大端小端以及如何判断大端小端

小端就是低位字节放在内存的低地址端, 大端就是低位字节放在内存的高地址端

1
2
3
4
5
6
7
8
int JudgeSystem(void)
{
int a = 1;
char * p = (char *)&a;

//如果是小端则返回1,如果是大端则返回0
return *p;
}

或者根据联合体来判断该系统是大端还是小端。因为联合体变量总是从低地址存储。
在这里插入图片描述

Linux 常用命令

ls命令
ls –l # 以详情模式(long listing fashion)列出文件夹的内容。
ls –a # 列出文件夹里的所有内容,包括以”.”开头的隐藏文件。
mkdir命令
“mkdir”(Make directory)命令在命名路径下创建新的目录。然而如果目录已经存在了,那么它就会返回一个错误信息”不能创建文件夹,文件夹已经存在了”(“cannot create folder, folder already exists”)
grep命令
通常用于对一些命令的输出进行筛选加工
grep [-acinv] [–color=auto] ‘查找字符串’ filename
ls -l | grep -i file # 把ls -l的输出中包含字母file(不区分大小写)的内容输出
cp命令
cp -a file1 file2 #连同文件的所有特性把文件file1复制成文件file2
cp file1 file2 file3 dir #把文件file1、file2、file3复制到目录dir中
mv命令
mv file1 file2 file3 dir # 把文件file1、file2、file3移动到目录dir中
mv file1 file2 # 把文件file1重命名为file2
rm命令
rm -i file # 删除文件file,在删除之前会询问是否进行该操作
rm -fr dir # 强制删除目录dir中的所有文件
ps命令
ps aux # 查看系统所有的进程数据
vim命令
用于文本编辑
输入 i ,退出命令模式,进入INSERT模式
开始修改内容……
按 esc 键,退出INSERT模式,进入命令模式
再输入 :wq,保存文件,退出vi编辑器
time命令
$ time date
Sun Mar 26 22:45:34 GMT-8 2006 # 命令”date”的执行结果
real 0m0.136s # 实际时间
user 0m0.010s # 用户CPU时间
sys 0m0.070s # 系统CPU时间
touch 命令
“touch”命令代表了将文件的访问和修改时间更新为当前时间。touch命令只会在文件不存在的时候才会创建它。如果文件已经存在了,它会更新时间戳,但是并不会改变文件的内容。
chmod 命令
chmod 777 abc.sh # 为拥有者,用户所在组和其它用户提供读,写,执行权限。
tar命令
tar -zxvf abc.tar.gz # (记住’z’代表了.tar.gz)
tar -jxvf abc.tar.bz2 # (记住’j’代表了.tar.bz2) 压缩的更好但是也更慢

awk的使用

awk [-F field-separator] ‘commands’ input-file(s)
1、找到当前文件夹下所有的文件和子文件夹,并显示文件大小
在这里插入图片描述
$ ls –l | awk ‘{print $5 “\t” $9}’

2、找到当前文件夹下所有的文件和子文件夹,并显示文件大小,并显示排序
> ls -l | awk ‘BEGIN {COUNT = -1; print “BEGIN COUNT”}
{COUNT = COUNT + 1; print COUNT”\t”$5”\t”$9}
END {print “END, COUNT = “COUNT}’

3、找到当前文件夹下所有的子文件夹,并显示排序
> ls -l | awk ‘BEGIN {print “BEGIN COUNT”} /4096/{print NR”\t”$5”\t”$9}
END {print “END”}’

Linux下怎么得到一个文件的100到200行

sed -n ‘100,200p’ inputfile
awk ‘NR>=100&&NR<=200{print}’ inputfile
head -200 inputfile|tail -100

OS缺页置换算法

当访问一个内存中不存在的页,并且内存已满,则需要从内存中调出一个页或将数据送至磁盘对换区,替换一个页,这种现象叫做缺页置换。当前操作系统最常采用的缺页置换算法如下:
先进先出(FIFO)算法:置换最先调入内存的页面,即置换在内存中驻留时间最久的页面。按照进入内存的先后次序排列成队列,从队尾进入,从队首删除。
最近最少使用(LRU)算法: 置换最近一段时间以来最长时间未访问过的页面。根据程序局部性原理,刚被访问的页面,可能马上又要被访问;而较长时间内没有被访问的页面,可能最近不会被访问。
当前最常采用的就是LRU算法。

虚拟内存置换的方式

比较常见的内存替换算法有:FIFO,LRU,LFU,LRU-K,2Q。
1、FIFO(先进先出淘汰算法)
思想:最近刚访问的,将来访问的可能性比较大。
实现:使用一个队列,新加入的页面放入队尾,每次淘汰队首的页面,即最先进入的数据,最先被淘汰。
弊端:无法体现页面冷热信息
2、LFU(最不经常访问淘汰算法)
思想:如果数据过去被访问多次,那么将来被访问的频率也更高。
实现:每个数据块一个引用计数,所有数据块按照引用计数排序,具有相同引用计数的数据块则按照时间排序。每次淘汰队尾数据块。
开销:排序开销。
弊端:缓存颠簸。
3、LRU(最近最少使用替换算法)
思想:如果数据最近被访问过,那么将来被访问的几率也更高。
实现:使用一个栈,新页面或者命中的页面则将该页面移动到栈底,每次替换栈顶的缓存页面。
优点:LRU算法对热点数据命中率是很高的。
缺陷:
1)缓存颠簸,当缓存(1,2,3)满了,之后数据访问(0,3,2,1,0,3,2,1。。。)。
2)缓存污染,突然大量偶发性的数据访问,会让内存中存放大量冷数据。
4、LRU-K(LRU-2、LRU-3)
思想:最久未使用K次淘汰算法。
LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
实现:
1)数据第一次被访问,加入到访问历史列表;
2)如果数据在访问历史列表里后没有达到K次访问,则按照一定规则(FIFO,LRU)淘汰;
3)当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列删除,将数据移到缓存队列中,并缓存此数据,缓存队列重新按照时间排序;
4)缓存数据队列中被再次访问后,重新排序;
5)需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即:淘汰“倒数第K次访问离现在最久”的数据。
针对问题:
LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
5、2Q
类似LRU-2。使用一个FIFO队列和一个LRU队列。
实现
1)新访问的数据插入到FIFO队列;
2)如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;
3)如果数据在FIFO队列中被再次访问,则将数据移到LRU队列头部;
4)如果数据在LRU队列再次被访问,则将数据移到LRU队列头部;
5)LRU队列淘汰末尾的数据。
针对问题:LRU的缓存污染
弊端:
当FIFO容量为2时,访问负载是:ABCABCABC会退化为FIFO,用不到LRU。

linux常见的调度算法

调度算法:根据系统的资源分配策略所规定的资源分配算法。调度,当有多个进程(或多个进程发出的请求)要使用这些资源时,因为资源的有限性,必须按照一定的原则选择进程(请求)来占用资源。
1、先来先去服务(队列)
如果早就绪的进程排在就绪队列的前面,迟就绪的进程排在就绪队列的后面,那么先来先服务(FCFS: first come first service)总是把当前处于就绪队列之首的那个进程调度到运行状态。也就说,它只考虑进程进入就绪队列的先后,而不考虑它的下一个CPU周期的长短及其他因素。
有利于长作业以及CPU繁忙的作业,不利于短作业以及I/O繁忙的作业
2、最短优先(优先队列)
对预计执行时间短的作业(进程)优先分派处理机.通常后来的短作业不抢先正在执行的作业.
比FCFS缩短作业的等待时间;长作业的运行得不到保证。
3、轮转法(RoundRobin)
将系统中所有的就绪进程按照FCFS原则,排成一个队列。每次调度时将CPU分派给队首进程,让其执行一个时间片。时间片的长度从几个ms到几百ms。当进程用完分给它的时间片后,调度程序便停止该进程的运行,并把它放入就绪队列的末尾。
让每个进程在就绪队列中的等待时间与享受服务的时间成正比例。
4、多级反馈队列算法
设置多个就绪队列,分别赋予不同的优先级。新进程进入内存后,先投入队列1的末尾,按FCFS算法调度;若按队列1一个时间片未能执行完,则降低投入到队列2的末尾,同样按FCFS算法调度;如此下去,降低到最后的队列,则按”时间片轮转”算法调度直到完成。仅当较高优先级的队列为空,才调度较低优先级的队列中的进程执行。如果进程执行时有新进程进入较高优先级的队列,则抢先执行新进程,并把被抢先的进程投入原队列的末尾。
优点:
为提高系统吞吐量和缩短平均周转时间而照顾短进程。
为获得较好的I/O设备利用率和缩短响应时间而照顾I/O型进程。
不必估计进程的执行时间,动态调节。

linux文件系统

Linux ext2, ext3, ext4 文件系统:
Ext,全称extended file system, extfs,即扩展文件系统,Ext2就代表第二代文件扩展系统,
Linux ext2/ext3文件系统使用索引节点来记录文件信息,作用像windows的文件分配表。索引节点是一个结构,它包含了一个文件的长度、创建及修改时间、权限、所属关系、磁盘中的位置等信息。一个文件系统维护了一个索引节点的数组,每个文件或目录都与索引节点数组中的唯一一个元素对应。
Ext3/Ext4,是Ext2的升级版,只不过为了快速恢复文件系统,减少一致性检查的时间,增加了日志功能,所以Ext2被称为索引式文件系统,而Ext3/Ext4被称为日志式文件系统。

Ext家族是Linux支持度最广、最完整的文件系统,当我们格式化磁盘后,就已经为我们规划好了所有的inode/block/metadate等数据,这样系统可以直接使用,不需要再进行动态的配置,这也是它最优秀的特点,不过这也是它最显著的缺点,磁盘容量越大,格式化越慢,centos7.x已经选用xfs作为默认文件系统,xfs是一种适合大容量磁盘和处理巨型文件的文件系统。
在这里插入图片描述
虚拟文件系统(Virtual File System, 简称 VFS), 是 Linux 内核中的一个软件层,用于给用户空间的程序提供文件系统接口。正是由于在内核中引入了VFS,跨文件系统的文件操作才能实现,“一切皆是文件” 的口号才能承诺。

目录 描述
/ (root 文件系统) root 文件系统是文件系统的顶级目录。
/bin /bin 目录包含用户的可执行文件。
/boot 包含启动 Linux 系统所需要的静态引导程序和内核可执行文件以及配置文件。
/dev 该目录包含每一个连接到系统的硬件设备的设备文件。这些文件不是设备驱动,而是代表计算机上的每一个计算机能够访问的设备。
/etc 包含主机计算机的本地系统配置文件。
/home 主目录存储用户文件,每一个用户都有一个位于 /home 目录中的子目录(作为其主目录)。
/lib 包含启动系统所需要的共享库文件。
/media 一个挂载外部可移动设备的地方,比如主机可能连接了一个 USB 驱动器。
/mnt 一个普通文件系统的临时挂载点(如不可移动的介质),当管理员对一个文件系统进行修复或在其上工作时可以使用。
/opt 可选文件,比如供应商提供的应用程序应该安装在这儿。
/root 这不是 root(/)文件系统。它是 root 用户的主目录。
/sbin 系统二进制文件。这些是用于系统管理的可执行文件。
/tmp 临时目录。被操作系统和许多程序用来存储临时文件。用户也可能临时在这儿存储文件。注意,存储在这儿的文件可能在任何时候在没有通知的情况下被删除。
/usr 该目录里面包含可共享的、只读的文件,包括可执行二进制文件和库、man 文件以及其他类型的文档。
/var 可变数据文件存储在这儿。这些文件包括日志文件、MySQL 和其他数据库的文件、Web 服务器的数据文件、邮件以及更多。

《深入理解linux内核》第12章:虚拟文件系统 以及 第18章:Ext2和Ext3文件系统 以及 第16章:访问文件,可以让你深入了解linux文件系统

如何修改文件最大句柄数

linux默认最大文件句柄数是1024个,在linux服务器文件并发量比较大的情况下,系统会报”too many open files”的错误。故在linux服务器高并发调优时,往往需要预先调优Linux参数,修改Linux最大文件句柄数。
有两种方法:

  1. ulimit -n <可以同时打开的文件数>,将当前进程的最大句柄数修改为指定的参数(注:该方法只针对当前进程有效,重新打开一个shell或者重新开启一个进程,参数还是之前的值)
    首先用ulimit -a查询Linux相关的参数,如下所示:
    1
    2
    3
    max locked memory       (kbytes, -l) 64
    max memory size (kbytes, -m) unlimited
    open files (-n) 1024

其中,open files就是最大文件句柄数,默认是1024个。
修改Linux最大文件句柄数: ulimit -n 2048, 将最大句柄数修改为 2048个。

  1. 对所有进程都有效的方法,修改Linux系统参数
    vi /etc/security/limits.conf 添加

*  soft  nofile  65536
*  hard  nofile  65536
将最大句柄数改为65536
修改以后保存,注销当前用户,重新登录,修改后的参数就生效了

System V、Posix IPC

Posix是“可移植操作系统接口(Portable Operating System Interface )的首字母简写,但它并不是一个单一的标准,而是IEEE开发的一系列标准,System v是Unix操作系统众多版本的一个分支。将这两个名词放在一起讨论的一般是在Linux的进程间通信中,如在信号量编程中,有Posix信号量和System V信号量。
Posix信号量是基于内存的,即信号量值是放在共享内存中的。而System v信号量测试基于内核的,它放在内核里面,相同点都是它们都可以用于进程或者线程间的同步。
“Posix 信号量”,指的是单个计数信号量;“System v信号量”时,所指的是计数信号量集。

死锁产生的四个必要条件及如何预防

死锁是指两个或两个以上进程在执行过程中,因争夺资源而造成的下相互等待的现象。
死锁发生的四个必要条件:
互斥:一个资源每次只能被一个进程使用。
请求与保持:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
不可剥夺: 进程已获得的资源,在末使用完之前,不能强行剥夺,只能在使用后自己释放
循环等待: 若干进程之间形成一种头尾相接的循环等待资源关系。

通过破坏死锁产生的4个必要条件来预防死锁,由于资源互斥是资源使用的固有特性是无法改变的。
破坏”请求与保持条件“:
第一种方法静态分配:每个进程在开始执行时就申请他所需要的全部资源。
第二种是动态分配:每个进程在申请所需要的资源时他本身不占用系统资源。
破坏“不可剥夺”条件:
一个进程不能获得所需要的全部资源时便处于等待状态,等待期间他占有的资源将被隐式的释放重新加入到系统的资源列表中,可以被其他的进程使用。
破坏“循环等待”条件:
采用资源有序分配,将系统中的所有资源顺序编号,将紧缺的,稀少的采用较大的编号,每个进程按编号递增的请求资源,释放则相反

Linux的4种锁机制

互斥锁:mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒
读写锁:rwlock,分为读锁和写锁。处于读操作时,可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。 注意:写锁会阻塞其它读写锁。当有一个线程获得写锁在写时,读锁也不能被其它线程获取;写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。适用于读取数据的频率远远大于写数据的频率的场合。
自旋锁:spinlock,在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗,在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源。
RCU:即read-copy-update,在修改数据时,首先需要读取数据,然后生成一个副本,对副本进行修改。修改完成后,再将老数据update成新的数据。使用RCU时,读者几乎不需要同步开销,既不需要获得锁,也不使用原子指令,不会导致锁竞争,因此就不用考虑死锁问题了。而对于写者的同步开销较大,它需要复制被修改的数据,还必须使用锁机制同步并行其它写者的修改操作。在有大量读操作,少量写操作的情况下效率非常高。
互斥锁和读写锁的区别:
1)读写锁区分读者和写者,而互斥锁不区分
2)互斥锁同一时间只允许一个线程访问该对象,无论读写;读写锁同一时间内只允许一个写者,但是允许多个读者同时读对象。

怎么实现线程池

1.设置一个生产者消费者队列,作为临界资源
2.初始化n个线程,并让其运行起来,加锁去队列取任务运行
3.当任务队列为空的时候,所有线程阻塞
4.当生产者队列来了一个任务后,先对队列加锁,把任务挂在到队列上,然后使用条件变量去通知阻塞中的一个线程

如何实现linux的高并发,线程池的思想

线程池:
线程的开启和回收是要消耗系统性能的,对于大量使用线程的场景,使用线程池来进行管理,实现单个线程的复用,提高并发效率。
线程池里面的线程都是现成的而且能够重复使用,我们不需要临时创建大量线程,然后在任务结束时又销毁大量线程。一个理想的线程池能够合理地动态调节池内线程数量,既不会因为线程过少而导致大量任务堆积,也不会因为线程过多了而增加额外的系统开销。

线程池的处理流程如下:
1、判断线程池里的工作线程(线程池中实际执行任务的线程)是否都在执行任务,如果不是(工作线程空闲或者还有工作线程没有被创建)则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则进入下个流程。
2、线程池判断工作队列(用来存放没有处理的任务,提供一种缓冲机制)是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
3、判断线程池里的线程是否都处于工作状态(线程的闲与忙的状态是通过互斥量实现的),如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。
因为这里涉及到多个线程同时访问一个队列的问题,所以我们需要互斥锁来保护队列

生产者消费者并发程序

死循环+来连接时新建线程的方法效率有点低,怎么改进?
提前创建好一个线程池,用生产者消费者模型,创建一个任务队列,队列作为临界资源,有了新连接,就挂在到任务队列上,队列为空所有线程睡眠。改进死循环:使用select epoll这样的技术。
生产者消费者并发程序要求能够写得出来
生产者(producer)和消费者(consumer)问题是并发处理中最常见的一类问题,是一个多线程同步问题的经典案例。
有一个或者多个生产者产生某种类型的数据,并放置在固定大小的缓冲区中,一个消费者从缓冲区中取数据,每次取一项。任何时候,只有一个生产者或者消费者可以访问缓冲区;同时,消费者只能在缓冲区不为空的时候从缓冲区中读数据,生产者只能在缓冲区不为满的时候向缓冲区写入数据。
第一是缓冲区的互斥访问问题,任意时刻最多只能有一个线程访问缓冲区,Linux下可以使用互斥量pthread_mutex_t mutex对访问缓冲区的临界区代码进行保护。
第二是生产者和消费者对缓冲区访问的同步问题,生产者在缓冲区满时不能向缓冲区中写入数据,同时消费者在缓冲区空时不能读取数据,这里采用两个信号量sem_t room_sem(表示缓冲区有可用空间)和sem_t product_sem(表示缓冲区有可用产品)来对缓冲区进行同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h> // POSIX 操作系统 API 的访问功能
#include<pthread.h> //线程
#include<semaphore.h> //信号量

#define PRODUCER_NUM 5 //生产者数目
#define CONSUMER_NUM 5 //消费者数目
#define POOL_SIZE 11 //缓冲池大小
int pool[POOL_SIZE]; //缓冲区
int head=0; //缓冲池读取指针
int rear=0; //缓冲池写入指针
sem_t room_sem; //同步信号量,表示缓冲区有可用空间
sem_t product_sem; //同步信号量,表示缓冲区有可用产品
pthread_mutex_t mutex; //互斥量
void producer_fun(void *arg)
{
while (1)
{
sleep(1);
sem_wait(&room_sem);
pthread_mutex_lock(&mutex);
//生产者往缓冲池中写入数据
pool[rear] = 1;
rear = (rear + 1) % POOL_SIZE;
printf("producer %d write to pool\n", (int)arg);
printf("pool size is %d\n",(rear-head+POOL_SIZE)%POOL_SIZE);
pthread_mutex_unlock(&mutex);
sem_post(&product_sem);
}
}

void consumer_fun(void *arg)
{
while (1)
{
int data;
sleep(10);
sem_wait(&product_sem);
pthread_mutex_lock(&mutex);
//消费者从缓冲池读取数据
data = pool[head];
head = (head + 1) % POOL_SIZE;
printf("consumer %d read from pool\n", (int)arg);
printf("pool size is %d\n",(rear-head+POOL_SIZE)%POOL_SIZE);
pthread_mutex_unlock(&mutex);
sem_post(&room_sem);
}
}

int main()
{
pthread_t producer_id[PRODUCER_NUM];
pthread_t consumer_id[CONSUMER_NUM];
pthread_mutex_init(&mutex, NULL); //初始化互斥量
int ret = sem_init(&room_sem, 0, POOL_SIZE-1); //初始化信号量room_sem为缓冲池大小
if (ret != 0)
{
printf("sem_init error");
exit(0);
}
ret = sem_init(&product_sem, 0, 0); //初始化信号量product_sem为0,开始时缓冲池中没有数据
if (ret != 0)
{
printf("sem_init error");
exit(0);
}
for (int i = 0; i < PRODUCER_NUM; i++)
{
//创建生产者线程
ret =pthread_create(&producer_id[i], NULL, producer_fun, (void*)i);
if (ret != 0)
{
printf("producer_id error");
exit(0);
}
//创建消费者线程
ret = pthread_create(&consumer_id[i], NULL, consumer_fun, (void*)i);
if (ret != 0)
{
printf("consumer_id error");
exit(0);
}
}
for(int i=0;i<PRODUCER_NUM;i++)
{
pthread_join(producer_id[i],NULL);
pthread_join(consumer_id[i],NULL);
}

exit(0);
}

在这里插入图片描述

惊群效应

惊群是指多个进程/线程在等待同一资源时,每当资源可用,所有的进程/线程都来竞争资源的现象。
春天来了, 公园出现了很多麻雀. 而你恰巧有一个玉米粒. 扔出去,立马无数麻雀过来争抢.而最终只有一只麻雀得到了.而那些没有抢到的麻雀很累…….
对于操作系统来说,多个进程/线程在等待同一资源是,也会产生类似的效果,其结果就是每当资源可用,所有的进程/线程都来竞争资源,造成的后果:
1)系统对用户进程/线程频繁的做无效的调度、上下文切换,系统系能大打折扣。
2)为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。
accept惊群
多线程或多进程调用accept就会出现如下情况,当前多个进程阻塞在accept中,此时有客户端连接时,内核就会通知阻塞在accept的所有进程,这时就会造成惊群现象,也就是所有accept都会返回 但是只有一个能拿到有效的文件描述符,其他进程最后都会返回无效描述符。但在linux kernel 版本2.6 以上时,accept惊群的问题已经解决,大致方案就是选一个阻塞在accept的进程返回。
epoll惊群
但是在IO复用中, select/poll/epoll 还是存在这种现象,其原因就是这些阻塞函数造成了以上同样的问题。
如果多个进程/线程阻塞在监听同一个监听socket fd的epoll_wait上,当有一个新的连接到来时,所有的进程都会被唤醒。
避免惊群:Nginx采用互斥锁

fork、vfork、clone的区别

fork,vfork,clone都是linux的系统调用,主要用来linux创建新的子进程或线程(vfork创造出来的是线程)。
fork
fork:创建一个和当前进程映像一样的进程可以通过fork( )系统调用:

1
2
3
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

由于fork()后会产生一个和父进程完全相同的子进程,fork函数调用一次返回两次,子进程返回值为0,父进程返回子进程的进程ID,返回类型为pid_t。但子进程在此后多会exec系统调用,出于效率考虑,linux中引入了“写时复制技术-Copy-On-Write”。
写时复制 fork子进程完全复制父进程的栈空间,也复制了页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”,如果父子进程一直对这个页面是同一个页面,直到其中任何一个进程要对共享的页面“写操作”,这时内核会复制一个物理页面给这个进程使用,同时修改页表。而把原来的只读页面标记为“可写”,留给另外一个进程使用。
fork之后内核一般会通过将子进程放在队列的前面,以让子进程先执行,因为很多情况下子进程要马上执行exec,会清空栈、堆,这些和父进程共享的空间,加载新的代码段。exec将新程序代码加载(拷贝)到子进程的内存空间,替换掉原有的与父进程一模一样的代码和数据,让子进程空间运行全新的程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<sys/types.h>
#include<unistd.h>
#include<stdio.h>
int main()
{
pid_t pid;
pid = fork();
if(pid<0)
printf("error in fork!\n");
else if(pid == 0)
printf("I am the child process,ID is %d\n",getpid());
else
printf("I am the parent process,ID is %d\n",getpid());
return 0;
}

Wait():我们用fork启动一个进程时,子进程就有了自己的生命,并将独立地运行。有时,我们需要知道某个子进程是否已经结束了,我们可以通过wait函数安排父进程在子进程之后结束。wait系统调用会使父进程暂停执行,直到它的一个子进程结束为止。返回的是子进程的PID
Vfork
在实现写时复制之前,Unix的设计者们就一直很关注在fork后立刻执行exec所造成的地址空间的浪费。BSD的开发者们在3.0的BSD系统中引入了vfork( )系统调用。

1
2
3
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);

vfork创建出的子进程(线程)共享了父进程的变量,内存资源不独立,这一次是指针复制,2者的指针指向了同一个内存,所以子进程修改了变量,父进程的变量同样受到了影响。另外由vfork创造出来的子进程还会导致父进程挂起,除非子进程exit或者exec才会唤起父进程。
fork和vfork的区别:
1.fork( )的子进程拷贝父进程的数据段和代码段;vfork( )的子进程与父进程共享数据段

  1. fork( )的父子进程的执行次序不确定;vfork( )保证子进程先运行,在调用exec或exit之前与父进程数据是共享的,在它调用exec或exit之后父进程才可能被调度运行。
  2. vfork( )保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
  3. fork( )当需要改变共享数据段中变量的值,则拷贝父进程。

clone
clone函数功能强大,带了众多参数,因此由他创建的进程要比前面2种方法要复杂。clone可以让你有选择性的继承父进程的资源,你可以选择想vfork一样和父进程共享一个虚存空间,从而使创造的是线程,你也可以不和父进程共享,你甚至可以选择创造出来的进程和父进程不再是父子关系,而是兄弟关系。
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);
fn是函数指针,指向程序的指针,child_stack是为子进程分配系统堆栈空间,flags是标志用来描述你需要从父进程继承那些资源,arg就是传给子进程的参数
CLONE_VFORK 父进程被挂起,直至子进程释放虚拟内存资源
CLONE_VM 子进程与父进程运行于相同的内存空间

1
2
3
4
int FIBER_STACK = 8192;
void * stack = malloc(FIBER_STACK);//为子进程申请系统堆栈
clone(&do_something, (char *)stack + FIBER_STACK, CLONE_VM|CLONE_VFORK, 0);
int do_something(){}

僵尸进程

1)正常进程
正常情况下,子进程是通过父进程创建的,子进程再创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
waitt和waitpid区别和联系 在一个子进程终止前, wait 使其调用者阻塞,而waitpid 有一选择项,可使调用者不阻塞。waitpid并不等待第一个终止的子进程—它有若干个选择项,可以控制它所等待的特定进程。实际上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
$ kill -HUP `ps -A -ostat,ppid,pid |grep -e '^[Zz]' | awk'{print $2}'`

(1) 检查当前僵尸进程信息:

1
$ ps -ef | grep defunct | grep -v grep | wc -l

1

1
$ top | head -2

在这里插入图片描述
(2) 获得杀僵尸进程语句

1
2
$ ps -ef | grep defunct | grep -v grep | awk '{print "kill -9 " $2,$3}'  
//僵尸进程数会大大减少

(3)过一会儿检查当前僵尸进程信息

1
$ ps -ef | grep defunct | grep -v grep | wc -l

(4) 再次获得杀僵尸进程语句

1
2
$ ps -ef | grep defunct | grep -v grep | awk '{print "kill -18 " $3}'
//使用信号量18杀其父进程, 僵尸进程应该会全部消失

清除ZOMBIE(僵尸)进程原理:

1
$ kill -18 PPID

PPID是其父进程, 这个信号是告诉父进程, 该子进程已经死亡了, 请收回分配给他的资源.如果还不行则看先看其父进程又无其他子进程, 如果有, 可能需要先kill其他子进程, 也就是兄弟进程.方法是:

1
2
$ kill -15 PID1 PID2   //PID1,PID2是僵尸进程的父进程的其它子进程.:
$ kill -15 PPID //再kill父进程

杀死进程:
Kill 234 //杀死pid 为234的进程
kill -TERM 234 //杀死进程和子进程 -15
kill -9 323 //彻底杀死进程
kill -HUP 456 //重跑 (restart) 进程
killall python //通过进程名字结束进程

5种IO模型

1.阻塞IO:
调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作
2.非阻塞IO:
非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。
3.信号驱动IO:
linux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO时间就绪,进程收到SIGIO信号。然后处理IO事件。
4.IO复用/多路转接IO:
linux用select/poll函数实现IO复用模型,这两个函数也会使进程阻塞,但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。知道有数据可读或可写时,才真正调用IO操作函数
5.异步IO:
linux中,可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。

阻塞,非阻塞,同步,异步

阻塞和非阻塞:调用者在事件没有发生的时候,一直在等待事件发生,不能去处理别的任务这是阻塞。调用者在事件没有发生的时候,可以去处理别的任务这是非阻塞。
同步和异步:调用者必须循环自己去查看事件有没有发生,这种情况是同步。调用者不用自己去查看事件有没有发生,而是等待着注册在事件上的回调函数通知自己,这种情况是异步

异步编程的事件循环

事件循环就是不停循环等待事件的发生,然后将这个事件的所有处理器,以及他们订阅这个事件的时间顺序依次依次执行。当这个事件的所有处理器都被执行完毕之后,事件循环就会开始继续等待下一个事件的触发,不断往复。当同时并发地处理多个请求时,以上的概念也是正确的,可以这样理解:在单个的线程中,事件处理器是一个一个按顺序执行的。即如果某个事件绑定了两个处理器,那么第二个处理器会在第一个处理器执行完毕后,才开始执行。在这个事件的所有处理器都执行完毕之前,事件循环不会去检查是否有新的事件触发。在单个线程中,一切都是有顺序地一个一个地执行的!

page cache

加快从磁盘读取文件的速率。page cache中有一部分磁盘文件的缓存,因为从磁盘中读取文件比较慢,所以读取文件先去page cache中去查找,如果命中,则不需要去磁盘中读取,大大加快读取速度。在 Linux 内核中,文件的每个数据块最多只能对应一个 Page Cache 项,它通过两个数据结构来管理这些 Cache项,一个是radix tree,另一个是双向链表。Radix tree 是一种搜索树,Linux内核利用这个数据结构来通过文件内偏移快速定位Cache 项

select、poll、epoll的区别

IO多路复用:一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。Linux中的 select,poll,epoll 都是IO多路复用的机制。
epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现。

select 时间复杂度O(n)
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。
缺点:单个进程可监视的fd数量被限制;线性扫描,即采用轮询的方法,效率较低;需要维护一个用来存放大量fd的数据结构,用户空间和内核空间在传递该结构时复制开销大。
它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流,只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。

poll 时间复杂度O(n)
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,但是它没有最大连接数的限制,原因是它是基于链表来存储的。
poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

epoll 时间复杂度O(1)
epoll可以理解为event poll,epoll会把哪个流发生了怎样的I/O事件通知我们。
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,并且只会通知一次。
水平触发——只要满足条件,就触发一个事件(只要有数据没有被获取,内核就不断通知你);
边缘触发——每当状态变化时,触发一个事件。
epoll的优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
3、epoll使用mmap减少复制开销。

select、poll、epoll区别
| 区别 | select| poll | epoll|
|–|–|–|–|
| 支持一个进程所能打开的最大连接数| 单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是32*32)| poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的 | 虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接|
| FD剧增后带来的IO效率问题| 因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。| 同左 | 因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。|
| 消息传递方式 | 内核需要将消息传递到用户空间,都需要内核拷贝动作 | 同左 | epoll通过内核和用户空间共享一块内存来实现的。|

epoll怎么实现的

Linux epoll机制是通过红黑树和双向链表实现的。3个epoll系统调用:

1
2
3
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

Linux epoll机制是通过红黑树和双向链表实现的。 首先通过epoll_create()系统调用在内核中创建一个eventpoll类型的句柄,其中包括红黑树根节点和双向链表头节点。然后通过epoll_ctl()系统调用,向epoll对象的红黑树结构中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。最后通过epoll_wait()系统调用判断双向链表是否为空,如果为空则阻塞。当文件描述符状态改变,fd上的回调函数被调用,该函数将fd加入到双向链表中,此时epoll_wait函数被唤醒,返回就绪好的事件。

进程状态转换图,动态就绪,静态就绪,动态阻塞,静态阻塞

1、进程的五种基本状态:
在这里插入图片描述
创建状态:进程正在被创建
就绪状态:进程被加入到就绪队列中等待CPU调度运行
执行状态:进程正在被运行
等待阻塞状态:进程因为某种原因,比如等待I/O,等待设备,而暂时不能运行。
终止状态:进程运行完毕
2、交换技术
当多个进程竞争内存资源时,会造成内存资源紧张,并且,如果此时没有就绪进程,处理机会空闲,I/0速度比处理机速度慢得多,可能出现全部进程阻塞等待I/O。
针对以上问题,提出了两种解决方法:
1)交换技术:换出一部分进程到外存,腾出内存空间。
2)虚拟存储技术:每个进程只能装入一部分程序和数据。
在交换技术上,将内存暂时不能运行的进程,或者暂时不用的数据和程序,换出到外存,来腾出足够的内存空间,把已经具备运行条件的进程,或进程所需的数据和程序换入到内存。
从而出现了进程的挂起状态:进程被交换到外存,进程状态就成为了挂起状态。
3、活动阻塞,静止阻塞,活动就绪,静止就绪
1)活动阻塞:进程在内存,但是由于某种原因被阻塞了。
2)静止阻塞:进程在外存,同时被某种原因阻塞了。
3)活动就绪:进程在内存,处于就绪状态,只要给CPU和调度就可以直接运行。
4)静止就绪:进程在外存,处于就绪状态,只要调度到内存,给CPU和调度就可以运行。
从而出现了:
活动就绪 —— 静止就绪 (内存不够,调到外存)
活动阻塞 —— 静止阻塞 (内存不够,调到外存)
执行 —— 静止就绪 (时间片用完)

伙伴系统、slab缓存

伙伴系统、slab缓存是linux系统物理内存分配时所用到的技术。Linux内核内存管理的一项重要工作就是如何在频繁申请释放内存的情况下,避免碎片的产生。Linux采用伙伴系统解决外部碎片的问题,采用slab解决内部碎片的问题。
伙伴系统 (分配的大小由一个页框4k到1024个页框4M)
使用场景:内核中很多时候要求分配连续页,为快速检测内存中的连续区域采用的一种技术。
Linux内核引入了伙伴系统,把所有的空闲页框分组为11个块链表,每个块链表分别包含大小为1、2、4、8、16、32、64、128、256、512和1024个连续页框的页框块。最大可以申请1024个连续页框,也即4MB大小的连续空间。
假设要申请一个256个页框的块,先从256个页框的链表中查找空闲块,如果没有,就去512个页框的链表中找,找到了即将页框分为两个256个页框的块,一个分配给应用,另外一个移到256个页框的链表中。如果512个页框的链表中仍没有空闲块,继续向1024个页框的链表查找,如果仍然没有,则返回错误。
页框块在释放时,会主动将两个连续的页框块合并成一个较大的页框块。
slab缓存
适用场景:内核本身经常需要比完整页帧小的多的内存块。
slab的目的在于避免内部碎片。从buddy系统获取的内存至少是一个页,也就是4K,如果仅仅需要8字节的内存,显然巨大的内部碎片无法容忍。
slab从buddy系统申请空间,将较大的连续内存拆分成一系列较小的内存块。申请空间时从slab中获取大小最相近的小块内存,这样可以有效减少内部碎片。在slab最大的块为8K,slab中所有块在物理上也是连续的。
slab分配器是基于对象进行管理的,相同类型的对象归为一类(如进程描述符就是一类),每当要申请这样一个对象,slab分配器就从一个slab列表中分配一个这样大小的单元出去,而当要释放时,将其重新保存在该列表中,而不是直接返回给伙伴系统,从而避免这些内碎片。

在内核中想要分配一段连续的内存,首先向slab系统申请,如果不满足(超过两个页面,也就是8K),直接向buddy系统申请。如果还不满足(超过4M,也就是1024个页面),将无法获取到连续的物理地址。可以通过vmalloc获取虚拟地址空间连续,但物理地址不连续的更大的内存空间。

linux内核中的Timer 定时器机制

hrtimer采用红黑树进行高精度定时器的管理,而不是时间轮;

高精度时钟定时器不在依赖系统的tick中断,而是基于事件触发。
旧内核的定时器实现依赖于系统定时器硬件定期的tick,基于该tick,内核会扫描timer wheel处理超时事件,会更新jiffies,wall time(墙上时间,现实时间),process的使用时间等等工作。
新的内核不再会直接支持周期性的tick,新内核定时器框架采用了基于事件触发,而不是以前的周期性触发。新内核实现了hrtimer(high resolution timer):于事件触发。
hrtimer的工作原理:
通过将高精度时钟硬件的下次中断触发时间设置为红黑树中最早到期的Timer 的时间,时钟到期后从红黑树中得到下一个 Timer 的到期时间,并设置硬件,如此循环反复。

docker

下次单独出篇博客总结。。。。。

0%