操作系统

1、操作系统(Operating System,OS)

控制和管理整个计算机系统的硬件和软件资源,并合理地组织调度计算机的工作和资源的分配,以提供给用户和其他软件方便的接口和环境的程序集合。

为用户提供服务,使用户能在计算机上使用各种应用程序来操作计算机资源,是用户和计算机硬件系统之间的接口

2、处理机

计算机系统中存储程序和数据,并按照程序规定的步骤执行指令的部件。
包括中央处理器、主存储器、I/O接口,处理器+外围设备(鼠标键盘之类)构成完整的操作系统

程序是描述处理机完成某项任务的指令序列。
指令则是处理机能直接解释、执行的信息单位。

3、中央处理器(CPU,Central Processing Unit)

是一块超大规模的集成电路,是一台计算机的运算核心和控制核心。
它的功能主要是解释计算机指令以及处理计算机软件中的数据

4、内核

操作系统的最基本部分、核心,决定一个程序在什么时候对某部分硬件操作多长时间

提供操作系统的最基本的功能,是操作系统工作的基础,它负责管理系统的进程、内存、设备驱动程序、文件和网络系统,决定着系统的性能和稳定性

5、操作系统的四个特性

并发:同一段时间内多个程序执行(与并行区分,并行指的是同一时刻有多个事件,多处理器系统可以使程序并行执行)

分时复用cpu
并发,在操作系统中,一个时间段中有多个进程都处于已启动运行到运行完毕之间的状态。但,任一个时刻点上仍只有一个进程在运行
例如,当下,我们使用计算机时可以边听音乐边聊天边上网。 若笼统的将他们均看做一个进程的话,为什么可以同时运行呢,因为并发。

共享:系统中的资源可以被内存中多个并发执行的进、线程共同使用
虚拟:通过分时复用(如分时系统)以及空分复用(如虚拟内存)技术把一个物理实体虚拟为多个
异步:系统进程用一种走走停停的方式执行,(并不是一下子走完),进程什么时候以怎样的速度向前推进是不可预知的

6、操作系统的目标和功能

处理机管理:
处理机的运行以进程(或线程)为基本单位,对处理机的管理可归结为对进 程的管理。
管理进程的资源共享:进程控制、进程同步、进程通信、死锁处理、处理机调度

存储器管理:
给多个程序的运行提供良好环境,方便用户使用+提高内存利用率

内存分配、地址映射、内存保护与共享、内存扩充

文件管理:
计算机中的信息以文件形式存在。 文件存储空间管理、目录管理、文件读写管理和保护
设备管理: 完成用户的I/O请求,方便用户使用各种设备,并提高设备利用率

缓冲管理:设备分配、设备处理、虚拟设备

7、用户态 内核态

内核态
cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可 以将自己从一个程序切换到另一个程序。

用户态
只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺, cpu资源可以被其他程序获取。 最大的区别就是权限不同,在运行在用户态下的程序不能直接访问操作系统内核 数据结构和程序。

为什么要有这两态:
需要限制不同的程序之间的访问能力防止他们获取别的程序的内存数据, 或者获取外围设备的数据,并发送到网络,CPU划分出两个权限等级 – 用户态 和内核态。

什么时候转换
1、系统调用: 用户进程主动发起的。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作, 比如fork()就是执行一个创建新进程的系统调用、printf等 用户程序使用系统调用,系统调用会转换为内核态并调用操作系统

2、异常: 会从当前运行进程切换到处理次此异常的内核相关程序中(缺页异常)

3、外围设备的中断: 所有程序都运行在用户态,但在从硬盘读取数据、或从键盘输入时,这些事 情只有操作系统能做,程序需要向操作系统请求以程序的名义来执行这些操作。 这个时候用户态程序切换到内核态。

用户态调用内核态的方式:
1、系统调用
2、库函数
3、shell脚本

8、线程状态

创建:创建成功,有了相应的内存空间和其他资源,但还未开始执行
就绪:等待CPU调度,进入线程队列排队,等待CPU服务
运行:获得处理器资源,正在执行。
阻塞:需要进行耗时的输入输出操作时,要等阻塞清除才能进入队列排队
终止:stop()、destory()或run()结束后,不在具有继续运行的能力

9、进程三态的转化(也有说5态的,和线程差不多)

就绪状态: 进程已处于准备运行的状态,即进程获得了除处理机之外的一切所需资源, 一旦得到处理机即可运行。(只缺处理机这个资源)

运行状态: 进程正在处理机上运行。在单处理机环境下,每一时刻最多只有一个进程处 于运行状态。

阻塞状态,又称等待状态:进程正在等待某一事件而暂停运行,如等待某资源为可用 (不包括处理机)或等待输入/输出完成。即使处理机空闲,该进程也不能运行。(缺除了处理机之外的其他资源)

10、僵尸进程

僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。

11、孤儿进程

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

12、进程和线程、协程

进程
一个在内存中运行的应用程序。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程,比如在Windows系统中,一个运行的xx.exe就是一个进程。

线程
进程中的一个执行任务(控制单元),负责当前进程中程序的执行。一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可共享数据。

与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

进程与线程的区别总结
在引入了线程的操作系统中,通常一个进程都有若干个线程,至少包含一个线程。

根本区别进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位

资源开销每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

内存分配同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的

影响关系一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。

执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

多进程和多线程区别
多进程:操作系统中同时运行的多个程序

多线程:在同一个进程中同时运行的多个任务

举个例子,多线程下载软件,可以同时运行多个线程,但是通过程序运行的结果发现,每一次结果都不一致。 因为多线程存在一个特性:随机性。造成的原因:CPU在瞬间不断切换去处理各个线程而导致的,可以理解成多个线程在抢CPU资源。

多线程并不能提高运行速度,但可以提高运行效率,让CPU的使用率更高。 但是如果多线程有安全问题或出现频繁的上下文切换时,运算速度可能反而更低。

这篇博客讲的很全,可以参考

协程

一个进程中有多个线程,自然一个线程中就有多个协程。

  1. 多线程之间可以并发运行,提高效率,多协程之间也可以并发运行。

  2. 多线程在访问资源时可能会出现安全问题,需要加锁。多协程则没有这方面的问题,因为它们位于一个线程内,一个线程是不需要加锁的。

  3. 多线程的切换需要操作系统参与且在内核空间完成。多协程的切换在用户空间完成,不需要操作系统干预,因为操作系统压根就不知道协程的存在。

  4. 多协程该如何切换(也称调度)呢?也分为主动和被动两种。

一些程序语言里使用yield关键词让协程主动让出CPU。
一个协程调度器在协程的时间片用完后把它切出去,被动让出CPU。

不过后面这个被动方式,可能会造成多协程间产生资源竞争的安全问题,因为一个协程正访问着资源呢,就被赶跑了,下一个协程又来了,极有可能产生问题。

多线程的切换,主要有操作系统的切换加上代码主动让出CPU两种形式。由于操作系统无法感知到协程的存在,所以在协程切换中充当操作系统角色的只能是语言的VM(虚拟机,类似于Java的JVM)了。

所以协程的实现,其实是需要语言本身的完全支持的,既包括语言语法的支持,也包括语言底层的虚拟机的支持。

语法来指导程序员如何定义和使用协程,也可以认为是以更加合理的方式来运用协程。虚拟机需要实现协程调度器,就像线程调度器那样,以防止一个协程总是老占着坑位不让别人用。

举例子:

void main() {
    D();
    E();
    F();
}

一个线程里的代码的执行路径也是确定,不会出现随机性的。执行情况就是先执行D,D完了在执行E,E完了在执行F。F完了main函数就结束了。
但是对于协程,比如线程正在执行协程X呢,执行了一会,突然跑去执行协程Y了,执行了一会,又回到协程X继续执行了。这种切换理论上可以随意进行,因为它们都在用户空间,而且在同一个线程里,和if/else没啥区别。
https://mp.weixin.qq.com/s/RKdpNCAwCVPjqMCWKdTVMQ

13、进程间的通信方式

管道、消息队列、共享内存、信号量

1、管道是一种半双工的通信方式,数据只能单向流动,无名管道只能在具有亲缘关系的进程间流动,进程的亲缘关系通常是父子进程。有名管道可以在没有亲缘关 系的进程间使用。

管道的通知机制类似于缓存,就像一个进程把数据放在某个缓存区域,然后等着另外一个进程去拿,并且是管道是单向传输的。

显然,这种通信方式效率低下,a 进程给 b 进程传输数据,只能等待 b 进程取了数据之后 a 进程才能返回。

netstat -tulnp | grep 8080
//把 netstat -tulnp 的输出结果作为 grep 8080 这条命令的输入。

netstat -nlp | grep 80 //找出占用80端口的进程

缺点:管道不适合频繁通信的进程。
优点:简单,能够保证我们的数据已经真的被其他进程拿走了。我们平时用 Linux 的时候,也算是经常用。

2、消息队列
是消息的链表,存放在内核中并由消息队列标识符标识。由内核维护,大家都可以访问

把进程的数据放在某个内存之后可以马上返回,无需等待其他进程来取就返回。

消息队列的通信模式,例如 a 进程要给 b 进程发送消息,只需要把消息放在对应的消息队列里就行了,b 进程需要的时候再去对应的
消息队列里取出来。同理,b 进程要个 a 进程发送消息也是一样。这种通信方式也类似于缓存吧。

缺点:如果 a 进程发送的数据占的内存比较大,并且两个进程之间的通信特别频繁的话,消息队列模型就不大适合了。因为 a 发送的数据很大的话,意味发送消息(拷贝)这个过程需要花很多时间来读内存。

3、共享内存 就是映射一段能被其它进程访问的内存,这段共享内存由一个进程创建,但是多个进程可以访问。读写操作时需要用同步互斥的工具,保证在一个进程对这段内存进行访问的时候其他进程不能同时进来

系统加载一个进程的时候,分配给进程的内存并不是实际物理内存,而是虚拟内存空间。那么我们可以让两个进程各自拿出一块虚拟地址空间来,然后映射到相同的物理内存中,这样,两个进程虽然有着独立的虚拟内存空间,但有一部分却是映射到相同的物理内存,这就完成了内存共享机制了

4、信号量是一个计数器用来控制多个进程对资源的访问,它通常作为一种锁机制。

信号量的本质就是一个计数器,用来实现进程之间的互斥与同步。例如信号量的初始值是 1,然后 a 进程来访问内存1的时候,我们就把信号量的值设为 0,然后进程b 也要来访问内存1的时候,看到信号量的值为 0 就知道已经有进程在访问内存1了,这个时候进程 b 就会访问不了内存1。所以说,信号量也是进程之间的一种通信方式。

5、socket通信 借助网络实现进程间的通信。

https://mp.weixin.qq.com/s/5CbYGrylSKx1JwtOiW3aOQ

进程间通讯的7种方式
https://mp.weixin.qq.com/s/5CbYGrylSKx1JwtOiW3aOQ

14、如何减小内存碎片

现在普遍采用的段页式内存分配方式就是将进程的内存区域分为不同的段,然后将每一段由多个固定大小的页组成。 通过页表机制,使段内的页可以不必连续处于同一内存区域,从而减少了外部碎片,然而同一页内仍然可能存在少量的内部碎片,只是一页的内存空间本就较小,从而使可能存在的内部碎片也较少。

但这种分页的方式,程序需要记录的是内存页ID,每次使用时,需要从内存页ID翻译成实际内存地址,多了一次转换。

内存碎片产生原因及终极解决办法
内存分配中的伙伴系统、slab分配机制

15、页表

页表: 用来记录逻辑地址和实际存储地址之间的映射关系,以实现从页号到物理块号的映射。 访问分页系统中内存数据需要两次内存访问,一次从内存中访问页表,找到实际物理地址,第二次根据得到的物理地址访问内存

页表储存在内存中

逻辑空间->页表->物理空间

快表机制: 访问内存数据的时候先在快表里查询,如果查到了就可以直接读取相应的物理块号,如果没找到再访问页表,得到物理地址并访问,同时把该页表中的该映射项添加到块表中

两级页表或多级页表
基本分段储存管理方式

分段管理:每个段内部连续内存分配,但段与段之间是离散的,因此会用到段表,记录每段在内存中的起始地址和该段长度。 段表可以放在内存或寄存器中。

分页和分段的比较
是信息的物理单位,是出于系统内存利用率的角度提出的离散分配机制;
是信息的逻辑单位,每个段含有一组意义完整的信息,是出于用户角度提出的 内存管理机制

页的大小是固定的,由系统决定;
段的大小是不确定的,由用户决定

16、虚拟内存地址、物理内存地址

相当于从逻辑上扩充内存容量,在程序装入的时候,只把程序的一部分装入内存,就启动程序执行,执行过程中,访问的信息不在内存里时,操作系统将需要的部分调入内存,并把暂时不适用的内容换到外存上,腾出内存空间。 让应用程序认为他用了一个比实际内存大得多的存储器。

假设你的计算机是32位,那么它的地址总线是32位的,也就是它可以寻址0 ~ 0xFFFFFFFF(4G)的地址空间,但如果你的计算机只有256M的物理内0x~0x0FFFFFFF(256M),同时你的进程产生了一个不在这256M地址空间中的地址,那么计算机该如何处理呢?

回答这个问题前,先说明计算机的内存分页机制计算机会对虚拟内存地址空间(32位为4G)分页产生页(page),对物理内存地址空间(假设256M)分页产生页帧(page frame),这个页和页帧的大小是一样大的,所以呢,在这里,虚拟内存页的个数势必要大于物理内存页帧的个数。在计算机上有一个页表(page table),就是映射虚拟内存页到物理内存页的,更确切的说是页号到页帧号的映射,而且是一对一的映射。但是问题来了,虚拟内存页的个数 > 物理内存页帧的个数,岂不是有些虚拟内存页的地址永远没有对应的物理内存地址空间?不是的,操作系统是这样处理的。操作系统有个页面失效(page fault)功能。操作系统找到一个最少使用的页帧,让他失效,并把它写入磁盘,随后把需要访问的页放到页帧中,并修改页表中的映射,这样就保证所有的页都有被调度的可能了。这就是处理虚拟内存地址到物理内存的步骤。

虚拟内存地址由页号和偏移量组成。页号对应的映射到一个页帧。那么,说说偏移量。偏移量就是我上面说的页(或者页帧)的大小,即这个页(或者页帧)到底能存多少数据。举个例子,有一个虚拟地址它的页号是4,偏移量是20,那么他的寻址过程是这样的:首先到页表中找到页号4对应的页帧号(比如为8),如果页不在内存中,则用失效机制调入页,否则把页帧号和偏移量传给MMU(CPU的内存管理单元)组成一个物理上真正存在的地址,接着就是访问物理内存中的数据了。总结起来说,虚拟内存地址的大小是与地址总线位数相关,物理内存地址的大小跟物理内存条的容量相关。
在这里插入图片描述

17、内存布局(在虚拟内存中)

(高地址)
内核空间

环境变量:

命令行参数

栈区:编译器自动分配,存放函数的参数值、局部变量的值,系统自动回收用完 的内存

内存映射段:将硬盘文件映射到内存,可用于共享内存、动态共享库。

堆区:一般由程序员分配(malloc申请内存和free释放内存),如果不释放内存 容易因引起内存泄漏

未初始化数据段(BSS段):静态变量和全局变量,内存被分配后直到程序结束 之后才释放,保存未初始化的内存和静态变量。

初始化数据段:存一些字符串常量、数组名等,静态内存分配,保存已经初始化的全局和静态变量。可读可写

代码区:保存可执行的机器码和常量。

保留区: (低地址)

18、堆和栈的区别


先进后出,生长方向向下,系统自动分配回收,高效快速;
但有限制,数据 不灵活。申请内存时,只要栈的剩余空间大于所申请的空间,系统将为程序员提供内存,否则报栈溢出。

:向上生长,需要程序员自己申请并指明大小。堆里分布的内存是不连续的。 操作系统应该有记录空闲内存地址的链表,申请内存时遍历链表,找第一个空间大于申请空间的堆节点,分配内存。

数据存到栈里比堆更快。 因为栈是由系统会自动分配内存,堆需要自己分配和释放内存;另外访问堆的一个具体单元需要两次访问内存,一次获得指针,第二次才是真正的数据,而栈只要一次

19、页面置换算法

地址映射的过程中,如果页面中发现要访问的页面不在内存中,会产生缺页 中断。此时操作系统必须在内存里选择一个页面把他移出内存,为即将调入的页 面让出空间。选择淘汰哪一页的规则就是页面置换算法
分类:
1、最佳置换算法(理想):将当前页面中在未来最长时间内不会被访问的页置 换出去
2、先进先出:淘汰最早调入的页面
3、最近最久未使用 LRU:每个页面有一个t来记录上次页面被访问直到现在, 每次置换时置换t值最大的页面(用寄存器或栈实现)
4、时钟算法clock(也被称为最近未使用算法NRU):页面设置访问为,将页面链接为一个环形列表,页面被访问的时候访问位设为1。页面置换的时候,如果当前指针的访问位为0,置换,否则将这个值置为0,循环直到遇到访问位为0 的页面。
5、改进型Clock算法:在clock算法的基础上添加一个修改位,优先替换访问位 和修改位都是0的页面,其次替换访问位为0修改位为1的页面。
6、最少使用算法LFU:设置寄存器,记录页面被访问次数,每次置换当前访问 次数最少的

文件描述符

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

20、IO多路复用

应用场景:
设计一个高性能的网络服务器,这个服务器可以供多个客户端给同时进行连接,并且能处理这些客户端传上来的请求,要能处理高并发的请求。

多线程方式的话,需要上下文的不断切换,需要很多开销。
所以考虑单线程的方式

其实多路复用的实现有多种方式:select、poll、epoll

select

先理解一下select这个函数的形参都是什么

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
nfds:指定待测试的描述子个数,一般会加1,
readfds,writefds,exceptfds:指定了我们让内核测试读、写和异常条件的描述字
fd_set:为一个存放文件描述符的信息的结构体,可以通过下面的宏进行设置。
void FD_ZERO(fd_set *fdset);//清空集合
void FD_SET(int fd, fd_set *fdset);//将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset);//将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset);// 检查集合中指定的文件描述符是否可以读写

timeout:内核等待指定的描述字中就绪的时间长度
返回值:失败-1 超时0 成功>0

传的是一个fd_set的结构体,里面就是一个bitmap,fd_set是一组文件描述符(fd)的集合,它用一个位来表示一个fd。

fdset 实际就是bitmap, 对于32位机默认是1024位,64位机默认是2048位。保存了哪些fd需要被监听。
fdset从用户态拷贝到内核态
如果没有数据,select函数就会阻塞在那里
如果有数据,fdset对应的位会被置位,select函数会返回
然后在内核态中进行查询,看有没有数据,如果有数据,就把它从内核态读出来,做相应处理

提高效率:让内核去做判断,是否有数据到来

缺点:
bitmap位数有限,所以能够监听的最大端口数量有限
fd_set不可重用
有两次拷贝,开销大
查询O(n)复杂度

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <wait.h>
#include <signal.h>
#include <errno.h>
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>

#define MAXBUF 256

void child_process(void)
{
  sleep(2);
  char msg[MAXBUF];
  struct sockaddr_in addr = {0};
  int n, sockfd,num=1;
  srandom(getpid());
  /* Create socket and connect to server */
  sockfd = socket(AF_INET, SOCK_STREAM, 0);
  addr.sin_family = AF_INET;
  addr.sin_port = htons(2000);
  addr.sin_addr.s_addr = inet_addr("127.0.0.1");

  connect(sockfd, (struct sockaddr*)&addr, sizeof(addr));

  printf("child {%d} connected \n", getpid());
  while(1){
        int sl = (random() % 10 ) +  1;
        num++;
     	sleep(sl);
  	sprintf (msg, "Test message %d from client %d", num, getpid());
  	n = write(sockfd, msg, strlen(msg));	/* Send message */
  }

}

int main()
{
  char buffer[MAXBUF];
  int fds[5];
  struct sockaddr_in addr;
  struct sockaddr_in client;
  int addrlen, n,i,max=0;;
  int sockfd, commfd;
  fd_set rset;
  for(i=0;i<5;i++)
  {
  	if(fork() == 0)
  	{
  		child_process();
  		exit(0);
  	}
  }

  sockfd = socket(AF_INET, SOCK_STREAM, 0);
  memset(&addr, 0, sizeof (addr));
  addr.sin_family = AF_INET;
  addr.sin_port = htons(2000);
  addr.sin_addr.s_addr = INADDR_ANY;
  bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
  listen (sockfd, 5); 

  for (i=0;i<5;i++) 
  {//创建5个文件描述符,存进数组里面
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
    if(fds[i] > max)
    	max = fds[i];   //记录最大的文件描述符
  }
  
  while(1){
	FD_ZERO(&rset);
  	for (i = 0; i< 5; i++ ) {
  		FD_SET(fds[i],&rset);
  	}  //根据文件描述符创建对应的bitmap
  	//比如文件描述符1 2 5 7
  	//对应的bitmap:01100101,就是对应位置设置为1

   	puts("round again");
	select(max+1, &rset, NULL, NULL, NULL);

	for(i=0;i<5;i++) {
		if (FD_ISSET(fds[i], &rset)){
			memset(buffer,0,MAXBUF);
			read(fds[i], buffer, MAXBUF);
			puts(buffer);
		}
	}	
  }
  return 0;
}

select的过程

  1. 从用户空间拷贝fd_set到内核空间,也即从当前程序拷贝fd_set数组进内核,fd_set是什么可以参考百度百科,简单的说,是可以对socket进行操作的long型数组。
  2. 对所有的fd进行一次poll操作,即把当前进程挂载到fd上。
  3. poll方法会返回一个描述读写是否就绪的mask掩码,根据这个mask掩码给fd_set赋值
  4. 如果遍历完所有的fd都没有返回一个可读写的mask掩码,就会让select的进程进入休眠模式,直到发现可读写的资源后,重新唤醒等待队列上休眠的进程。如果在规定时间内都没有唤醒休眠进程,那么进程会被唤醒重新获得CPU,再去遍历一次fd。
  5. 只要有事件触发,系统调用返回,将fd_set从内核空间拷贝到用户空间,回到用户态,用户就可以对相关的fd作进一步的读或者写操作了。

select函数优缺点
缺点:两次拷贝耗时、轮询所有fd耗时,O(n)复杂度,支持的文件描述符太小
优点:跨平台支持

poll实现方式

先理解一下poll这个函数的形参是什么

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

pollfd:又是一个结构体

struct pollfd {
int fd; //文件描述符
short events; //请求的事件(请求哪种操作)
short revents; //返回的事件
};

后两个参数都与select的第一和最后一个参数概念一样,就不细讲了

返回值:失败-1 超时0 成功>0

有数据时,pollfd.revents置位,解决了select中每次开始时要重新生成bitmap的问题
poll函数返回

时间复杂度:O(n)
其和select不同的地方:采用链表的方式替换原有fd_set数据结构,而使其没有连接数(也就是文件描述符)的限制。

  for (i=0;i<5;i++) 
  {
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
    pollfds[i].events = POLLIN;
  }
  sleep(1);
  while(1){
  	puts("round again");
	poll(pollfds, 5, 50000);
 
	for(i=0;i<5;i++) {
		if (pollfds[i].revents & POLLIN){
			pollfds[i].revents = 0;
			memset(buffer,0,MAXBUF);
			read(pollfds[i].fd, buffer, MAXBUF);
			puts(buffer);
		}
	}
  }

epoll

epoll操作过程中会用到的重要函数

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);

int epoll_create(int size):创建一个epoll的句柄,size表示监听数目的大小。创建完句柄它会自动占用一个fd值,使用完epoll一定要记得close,不然fd会被消耗完。
int epoll_ctl:这是epoll的事件注册函数,和select不同的是select在监听的时候会告诉内核监听什么样的事件,而epoll必须在epoll_ctl先注册要监听的事件类型。
它的第一个参数返回epoll_creat的执行结果
第二个参数表示动作,用下面几个宏表示
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;

第三参数为监听的fd,第四个参数是告诉内核要监听什么事

int epoll_wait:等待事件的发生,类似于select的调用

 struct epoll_event events[5];
  int epfd = epoll_create(10); //10没有实际意义
  ...
  ...
  for (i=0;i<5;i++) 
  {
    static struct epoll_event ev;
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
    ev.events = EPOLLIN;
    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); 
  }
  
  while(1){
  	puts("round again");
  	nfds = epoll_wait(epfd, events, 5, 10000);
	
	for(i=0;i<nfds;i++) {
			memset(buffer,0,MAXBUF);
			read(events[i].data.fd, buffer, MAXBUF);
			puts(buffer);
	}
  }
  1. 调用epoll_create时,做了以下事情:
    1.1 内核帮我们在epoll文件系统里建了个file结点;
    1.2 在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket;
    1.3 建立一个list链表,用于存储准备就绪的事件。

  2. 调用epoll_ctl时,做了以下事情:
    2.1 把socket放到epoll文件系统里file对象对应的红黑树上;
    2.2 给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。

  3. 调用epoll_wait时,做了以下事情:
    观察list链表里有没有数据。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。

总结一下,就是epoll不需要通过遍历的方式,而是在内核中建立了file节点,并且通过注册响应事件的方式,当有响应事件发生时采取相应的措施,并把准备就绪的事件放入链表中,从而epoll只关心链表中是否有数据即可

epoll的优点

  1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个 数字一般远大于2048,
  2. IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方 式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数
  3. 支持电平触发和边沿触发(只告诉进程哪些文件描述符刚刚变为就绪状态,它只说 一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发)两种方 式,理论上边缘触发的性能要更高一些,但是代码实现相当复杂。
  4. mmap()文件映射内存的方式加速内核与用户空间的信息传递。epoll是通过内核于用户空间mmap同一块内存,避免了无畏的内存拷贝。

注意:只有存在大量的空闲连接和不活跃的连接的时候,使用epoll的效率才会比select/poll高

参考:
select和epoll区别

漫谈五种IO模型(主讲IO多路复用)

21、进程死锁

多个并发的进程中,如果每个进程都持有某种资源由等待其他进程释放它现在保持的资源,这些资源都只允许一个进程占用,结果两个进程都不能继续执行,也不会释放自己占有的资源,所以这种双方循环等待的现象回无限期持续, 发生死锁。
原因(4)
1、互斥条件:资源不能共享,只能一个进程用
2、请求与保持条件:已经得到资源的进程可以再次申请新的其他资源 3、非剥夺条件:已经分配的资源不能从相应进程中强制剥夺
4、循环等待条件:系统中若干进程形成环路,环路中的每个进程都在等待相邻进程正占用的资源

死锁处理
预防:破环四个原因中的一个或多个,但会影响到资源利用率及吞吐量 避免:在资源的动态分配中防止系统进入不安全状态
检测:死锁发生后,用一定的算法进行检测,并确定死锁相关的资源和进程,采取方法清楚死锁。
解除:对死锁相关进程,通过撤销或挂起的方式,释放一些资源

解决死锁的办法:
1、加锁顺序(线程按照一定的顺序加锁)

2、加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁 的请求,并释放自己占有的锁)

3、死锁检测

它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。 每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将 其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。

当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例 如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B 是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。 释放其中的一些线程。

22、调度算法

调度算法特点缺点
先来先服务调度按进程到达的先后顺序依次调度当前面有许多长进程时,短进程的响应时间太长
短作业优先调度选择队列中估计时间较短的先进行处理如果短进程源源不断加入队列,长进程们将永远得不到执行的机会,造成了「饥饿」现象
高响应比优先响应比 = (等待时间+要求服务时间)/ 要求服务时间,等待时间长,要求服务时间短(就是短进程)的进程更容易被选中,即响应比高的算法会先执行。每次调度前,都要重新计算所有等待进程的响应比
时间片轮转算法按进程到达的先后顺序放入队列,每个进程将轮流使用 CPU 资源,只不过在开始运行时,会打开定时器,如果定时器到时间(或者执行阻塞操作),进程将被迫「下机」并将其放到队伍尾部,循环,切换至下一个进程。(类似时分复用)切换进程是要开销的
优先级调度算法每个进程将被赋予一个优先级,用户进程的优先级不准高于内核进程的优先级。切换程序的时候,我会从优先级 1 的队列里选择一个进程,如果优先级 1 队列为空,才会选择优先级 2 中的进程,以此类推。为了保证低优先级进程不会饥饿,调高等待时间长的进程的优先级需要大量切换进程,还需要动态调节优先级

https://mp.weixin.qq.com/s/t0KtPy-JZ3rR4E4HmAenQQ

23、互斥

比如一个买票系统,几个线程执行流的冲突问题嘛,假设余票为1,线程1查询到余票大于0,线程2查询到余票大于0,余票减1,余票减1,导致一张票被卖了两次。本来应该一个线程订票操作结束后,另一个线程才能查询余票。像这样执行流交叉,肯定还会出现其它意想不到的问题。”

进程和另一个进程共享一部分内存空间,结果在使用同一个数据的时候,另一个进程把此进程刚写进去的数据覆盖掉了,是的此进程后面的计算全出错了。

解决方法
加锁
“加锁是个比喻,其实「锁」只是一个共享变量,有 OPEN 和 CLOSE 这两个值。一个进程,比如说 A,进入临界区之前,先检查锁是不是 OPEN 状态,如果是的话,就把锁改为 CLOSE 状态 ,这样其他进程在进入临界区时,会发现锁已经 CLOSE 了,那就让他们循环等待 ,直到 A 出临界区然后将锁打开。

临界区的互斥问题
如果 A 发现锁是开着的,但在 A 还没有关闭锁之前,切换到了进程 B ,那么 B 也会发现锁是开着的,那么 B 也将能够进入临界区!

计算机里有一条硬件支持的指令——TSL(test and set lock,测试并加锁),这条指令可以保证读字和写字的操作「不可分割」
在这里插入图片描述
让进程在进入临界区之前先调用 enter_region ,如果非0,就说明锁打开,并将锁设置为1,关闭锁,这个过程不可分割。如果锁已经被关闭(表现为非 0 ),就循环调用enter_region ,直到锁打开,然后再进入临界区。
出临界区之后,就调用 leave_region 把锁打开。

这个方法,解决了临界区的互斥问题,但是需要忙等待,浪费了 CPU 的资源。

改进的方法:信号量
信号量设置了两种操作,P(proberen,检测) 和 V(verhogen,增量) 。

Dijkstra 提出,P操作是检测信号量是否为正值,如果不是,就阻塞调用它的进程。 V操作能唤醒一个被阻塞的进程,让他恢复执行 。具体点的话就是这样:

// S 为信号量
P(s):
{
S = S - 1
if (S < 0)
    {
        调用该 P 操作的进程阻塞,并插入相应的阻塞队列;
    }
}
// S 为信号量
V(s):
{
S = S + 1
if (S <= 0)
    {
        从等待信号量 S 的阻塞队列里唤醒一个进程;
    }
}

最简单的一组线程举例子:

// 线程 A,B,C
P(S)
购票操作
V(S)

这里的 「购票操作」 就是我们要保护的临界区,我们要保证一次只能有一个线程进入。那我们就把 S 的初始值设为 1 。当线程 A 第一个调用 P(S) 后,S 的值就变成了 0 ,A 成功进入临界区。在 A 出临界区之前,线程 B 如果调用 P(S), S 就变成 -1 ,满足 S < 0 的判断条件,线程 B 就被阻塞了。等 A 调用 V(S) 后,S 的值又变成 0 ,满足 S <= 0,就会把线程 B 唤醒,B 就能进入临界区了。“

信号量的其他功能
S 的初始值可以控制有多少个线程进入临界区
使用信号量,能让两个进程做到同步

https://mp.weixin.qq.com/s/PdREY_n9Wd2u44xlFWWY4g
htt加粗样式ps://mp.weixin.qq.com/s/nk_OqSM9R_JcBkHj2do34A

24、重量级锁、自旋锁、轻量级锁、偏向锁、悲观、乐观锁等各种锁

重量级锁

我们知道,我们要进入一个同步、线程安全的方法时,是需要先获得这个方法的锁的,退出这个方法时,则会释放锁。如果获取不到这个锁的话,意味着有别的线程在执行这个方法,这时我们就会马上进入阻塞的状态,等待那个持有锁的线程释放锁,然后再把我们从阻塞的状态唤醒,我们再去获取这个方法的锁。

这种获取不到锁就马上进入阻塞状态的锁,我们称之为重量级锁

自旋锁

线程从运行态进入阻塞态这个过程,是非常耗时的,因为不仅需要保存线程此时的执行状态,上下文等数据,还涉及到用户态到内核态的转换。当然,把线程从阻塞态唤醒也是一样,也是非常消耗时间的。

自旋锁就是,如果此时拿不到锁,它不马上进入阻塞状态,而是等待一段时间,看看这段时间有没其他人把这锁给释放了。怎么等呢?这个就类似于线程在那里做空循环,如果循环一定的次数还拿不到锁,那么它才会进入阻塞的状态。

自适应自旋锁

自旋锁,每个线程循环等待的次数都是一样的,例如设置为 100次的话,那么线程在空循环 100 次之后还没拿到锁,就会进入阻塞状态了。

而自适应自旋锁就牛逼了,它不需要我们人为指定循环几次,它自己本身会进行判断要循环几次,而且每个线程可能循环的次数也是不一样的。而之所以这样做,主要是我们觉得,如果一个线程在不久前拿到过这个锁,或者它之前经常拿到过这个锁,那么我们认为它再次拿到锁的几率非常大,所以循环的次数会多一些。

而如果有些线程从来就没有拿到过这个锁,或者说,平时很少拿到,那么我们认为,它再次拿到的概率是比较小的,所以我们就让它循环的次数少一些。因为你在那里做空循环是很消耗 CPU 的。

所以这种能够根据线程最近获得锁的状态来调整循环次数的自旋锁,我们称之为自适应自旋锁。

轻量级锁

轻量级锁也称为自旋锁,无锁,是因为它不需要经过操作系统。

轻量级锁认为,当你在方法里面执行的时候,其实是很少刚好有人也来执行这个方法的,所以,当我们进入一个方法的时候根本就不用加锁,我们只需要做一个标记就可以了,也就是说,我们可以用一个变量来记录此时该方法是否有人在执行。也就是说,如果这个方法没人在执行,当我们进入这个方法的时候,采用CAS机制,把这个方法的状态标记为已经有人在执行,退出这个方法时,在把这个状态改为了没有人在执行了。

之所以要用CAS机制来改变状态,是因为我们对这个状态的改变,不是一个原子性操作,所以需要CAS机制来保证操作的原子性。不知道CAS的可以看这篇文章:并发的核心:CAS 是什么?Java8是如何优化 CAS 的?。

显然,比起加锁操作,这个采用CAS来改变状态的操作,花销就小多了

然而可能会说,没人来竞争的这种想法,那是你说的而已,那如果万一有人来竞争说呢?也就是说,当一个线程来执行一个方法的时候,方法里面已经有人在执行了。

所以轻量级锁适合用在那种,很少出现多个线程竞争一个锁的情况,也就是说,适合那种多个线程总是错开时间来获取锁的情况。

轻量级锁能提高程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢

偏向锁

偏向锁就更加牛逼了,我们已经觉得轻量级锁已经够轻,然而偏向锁更加省事,偏向锁认为,你轻量级锁每次进入一个方法都需要用CAS来改变状态,退出也需要改变,多麻烦。

偏向锁认为,其实对于一个方法,是很少有两个线程来执行的,搞来搞去,其实也就一个线程在执行这个方法而已,相当于单线程的情况,居然是单线程,那就没必要加锁了。

不过毕竟实际情况的多线程,单线程只是自己认为的而已了,所以呢,偏向锁进入一个方法的时候是这样处理的:如果这个方法没有人进来过,那么一个线程首次进入这个方法的时候,会采用CAS机制,把这个方法标记为有人在执行了,和轻量级锁加锁有点类似,并且也会把该线程的 ID 也记录进去,相当于记录了哪个线程在执行。

然后,但这个线程退出这个方法的时候,它不会改变这个方法的状态,而是直接退出来,懒的去改,因为它认为除了自己这个线程之外,其他线程并不会来执行这个方法。

然后当这个线程想要再次进入这个方法的时候,会判断一下这个方法的状态,如果这个方法已经被标记为有人在执行了,并且线程的ID是自己,那么它就直接进入这个方法执行,啥也不用做

你看,多方便,第一次进入需要CAS机制来设置,以后进出就啥也不用干了,直接进入退出。

然而,现实总是残酷的,毕竟实际情况还是多线程,所以万一有其他线程来进入这个方法呢?如果真的出现这种情况,其他线程一看这个方法的ID不是自己,这个时候说明,至少有两个线程要来执行这个方法论,这意味着偏向锁已经不适用了,这个时候就会从偏向锁升级为轻量级锁。

偏向锁适用于那种,始终只有一个线程在执行一个方法的情况。

悲观锁和乐观锁

最开始说的三种锁,重量级锁、自旋锁和自适应自旋锁,进入方法之前,就一定要先加一个锁,这种我们为称之为悲观锁。悲观锁总认为,如果不事先加锁的话,就会出事,这种想法确实悲观了点,这估计就是悲观锁的来源了。

而乐观锁却相反,认为不加锁也没事,我们可以先不加锁,如果出现了冲突,我们在想办法解决,例如 CAS 机制,上面说的轻量级锁,就是乐观锁的。不会马上加锁,而是等待真的出现了冲突,在想办法解决。

25、CAS机制

i++ 不是一个原子操作,所以是很难得到 100 的。

这里稍微解释下为啥会得不到 100(知道的可直接跳过), i++ 这个操作,计算机需要分成三步来执行。

1、读取 i 的值。
2、把 i 加 1.
3、把 最终 i 的结果写入内存之中。

所以,(1)、假如线程 A 读取了 i 的值为 i = 0,(2)、这个时候线程 B 也读取了 i 的值 i = 0。(3)、接着 A把 i 加 1,然后写入内存,此时 i = 1。(4)、紧接着,B也把 i 加 1,此时线程B中的 i = 1,然后线程 B 把 i 写入内存,此时内存中的 i = 1。也就是说,线程 A, B 都对 i 进行了自增,但最终的结果却是 1,不是 2.
那该怎么办呢?解决的策略一般都是给这个方法加个锁,如下

public class CASTest {
    static int i = 0;

    public synchronized static void increment() {
        i++;
    }
}

加了 synchronized 之后,就最多只能有一个线程能够进入这个 increment() 方法了。这样,就不会出现线程不安全了。
synchronized(从偏向锁到重量级锁)

然而,一个简简单单的自增操作,就加了 synchronized 进行同步,好像有点大材小用的感觉,加了 synchronized 关键词之后,当有很多线程去竞争 increment 这个方法的时候,拿不到锁的方法是会被阻塞在方法外面的,最后再来唤醒他们,而阻塞/唤醒这些操作,是非常消耗时间的

CAS 机制

1、线程从内存中读取 i 的值,假如此时 i 的值为 0,我们把这个值称为 k 吧,即此时 k = i = 0。

2、令 j = k + 1。

3、用 k 的值与内存中i的值相比,如果相等,这意味着没有其他线程修改过 i 的值,我们就把 j(此时为1) 的值写入内存;如果不相等(意味着i的值被其他线程修改过),我们就不把j的值写入内存,而是重新跳回步骤 1,继续这三个操作。

翻译成代码的话就是这样:

public static void increment() {
    do{
        int k = i;
        int j = k + 1;
    }while (compareAndSet(i, k, j))
}

如果你去模拟一下,就会发现,这样写是线程安全的。

这里可能有人会说,第三步的 compareAndSet 这个操作不仅要读取内存,还干了比较、写入内存等操作,这一步本身就是线程不安全的啊?

这个 compareAndSet 操作,他其实只对应操作系统的一条硬件操作指令,尽管看似有很多操作在里面,但操作系统能够保证他是原子执行的

对于一条英文单词很长的指令, compareAndSet 简称为 CAS

并发的核心:CAS 是什么?Java8是如何优化 CAS 的?

26、缓冲区

缓冲区 又称缓存。在内存空间中预留一定的存储空间,用来缓冲输入输出的数据。

原因:CPU直接从磁盘读数据速度慢,增加读写次数对磁盘性能会有影响;使用 缓冲区减小读写次数,CPU对缓冲区的操作速度也远大于磁盘的操作速度,增加 计算机的运行速度。(所以CPU会有高速缓冲区)

27、线程安全

在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。

一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。

线程安全与可重入
线程安全,是针对多线程而言的。与可重入联系起来,我们可以断定:

可重入函数必定是线程安全的,但线程安全的不一定是可重入的。

不可重入函数,函数调用结果不具有可再现性,可以通过互斥锁等机制使之能安全地同时被多个线程调用,那么,这个不可重入函数就是转换成了线程安全。

不可重入函数:如果有一个函数被设计成为这样:不同任务调用这个函数时可能修改其他任务调用这个函数的数据,从而导致不可预料的后果。

相反,肯定有一个安全的函数,这个安全的函数又叫可重入函数。那么什么是可重入函数呢?所谓可重入是指一个可以被多个任务调用的过程,任务在调用时不必担心数据是否会出错。

28、线程不安全的问题

计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。

为了处理这个问题,在CPU里面就有了高速缓存(Cache)的概念。当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

每个线程都有自己的工作内存,每个线程需要对共享变量操作时必须先把共享变量从主内存 load 到自己的工作内存,等完成对共享变量的操作时再 save 到主内存。

我举个简单的例子,比如cpu在执行下面这段代码的时候,

t = t + 1;

会先从高速缓存中查看是否有t的值,如果有,则直接拿来使用,如果没有,则会从主存中读取,读取之后会复制一份存放在高速缓存中方便下次使用。之后cup进行对t加1操作,然后把数据写入高速缓存,最后会把高速缓存中的数据刷新到主存中。

这一过程在单线程运行是没有问题的,但是在多线程中运行就会有问题了。

例如:

两个线程分别读取了t的值,假设此时t的值为0,并且把t的值存到了各自的高速缓存中,然后线程1对t进行了加1操作,此时t的值为1,并且把t的值写回到主存中。但是线程2中高速缓存的值还是0,进行加1操作之后,t的值还是为1,然后再把t的值写回主存。

此时,就出现了线程不安全问题了。

29、volatile关键字----可见性、有序性

可见性
在多线程环境下,某个共享变量如果被其中一个线程给修改了,其他线程能够立即知道这个共享变量已经被修改了,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取

例如我们上面说的,当线程1对t进行了加1操作并把数据写回到主存之后,线程2就会知道它自己工作空间内的t已经被修改了,当它要执行加1操作之后,就会去主存中读取。这样,两边的数据就能一致了。

假如一个变量被声明为volatile,那么这个变量就具有了可见性的性质了。这就是volatile关键的作用之一了。

有序性
如果一个变量被声明volatile的话,那么这个变量不会被进行重排序,也就是说,虚拟机会保证这个变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。

例如:

线程1读取了t的值,假如t = 0。之后线程2读取了t的值,此时t = 0。

然后线程1执行了加1的操作,此时t = 1。但是这个时候,处理器还没有把t = 1的值写回主存中。这个时候处理器跑去执行线程2,注意,刚才线程2已经读取了t的值,所以这个时候并不会再去读取t的值了,所以此时t的值还是0,然后线程2执行了对t的加1操作,此时t =1 。

volatile并不能完全保证一个变量的线程安全

原子操作
原子操作:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

int a = b + 1;

处理器在处理代码的时候,需要处理以下三个操作:

从内存中读取b的值。

进行a = b + 1这个运算

把a的值写回到内存中

而这三个操作处理器是不一定就会连续执行的,有可能执行了第一个操作之后,处理器就跑去执行别的操作的。

问题就出现在t = t + 1这句代码中。我们来分析一下

例如:

线程1读取了t的值,假如t = 0。之后线程2读取了t的值,此时t = 0。然后线程1执行了加1的操作,此时t = 1。但是这个时候,处理器还没有把t = 1的值写回主存中(因为可见性是发现被修改,就去内存中读,但是内存中的值还没有被更新)。这个时候处理器跑去执行线程2,注意,刚才线程2已经读取了t的值,所以这个时候并不会再去读取t的值了,所以此时t的值还是0,然后线程2执行了对t的加1操作,此时t =1 。

这个时候,就出现了线程安全问题了,两个线程都对t执行了加1操作,但t的值却是1。所以说,volatile关键字并不一定能够保证变量的安全性。

总结
这也是经典的内存不可见问题,那么把 t 加上 volatile 让内存可见是否能解决这个问题呢? 答案是:不能。因为volatile 只能保证可见性,不能保证原子性。多个线程同时读取这个共享变量的值,就算保证其他线程修改的可见性,也不能保证线程之间读取到同样的值然后相互覆盖对方的值的情况(因为可见性是发现被修改,就去内存中读,但是内存中的值还没有被更新,所以读不到)。

volatile能保证变量的线程安全问题

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  2. 变量不需要与其他状态变量共同参与不变约束。

30 大端、小端 和网络字节序

定义:
1、首先大小端是面向多字节类型定义的,比如2字节、4字节、8字节整型、长整型、浮点型等,单字节的字符串一般不用考虑。

2、大端小端存储、传输、以及接收处理需要对应。

3、大端(Big-Endian)就是高字节(MSB)在前,内存存储体现上,数据的高位更加靠近低地址。

4、小端(Little-Endian)就是低字节(LSB)在前,内存存储体现上,数据的低位更加靠近低地址。

网络字节序是大端:

UDP/TCP/IP协议规定:
把接收到的第一个字节当作高位字节看待,这就要求发送端发送的第一个字节是高位字节;

而在发送端发送数据时,发送的第一个字节是该数值在内存中的起始地址处对应的那个字节,也就是说,该数值在内存中的起始地址处对应的那个字节就是要发送的第一个高位字节(即:高位字节存放在低地址处);由此可见,多字节数值在发送之前,在内存中因该是以大端法存放的;

判断方法:
1、借助指针:

1 #include <iostream> 
2 using namespace std; 
3 int main() 
4 { 
5 int a = 1; 
6 if (*(char*)&a == 1) 
7 cout << "小端模式" << endl; 
8 else 
9 cout << "大端模式" << endl; 
10 return 0; 
11 } 
12 

2、借助联合体:

1 #include <iostream> 
2 using namespace std; 
3 union Test 
4 {
5 int a; 
6 char b; 
7 }; 

8 int main() 
9 { 
10 Test t; 
11 t.a = 1; 
12 if (t.b == 1) 
13 cout << "小端机器" << endl; 
14 else 
15 cout << "大端机器" << endl; 
16 return 0; 
17 } 

3、字节序与主机序列转换:

1 将主机字节序转换为网络字节序 
2 unit32_t htonl (unit32_t hostlong); 
3 unit16_t htons (unit16_t hostshort); 
4 //将网络字节序转换为主机字节序 
5 unit32_t ntohl (unit32_t netlong); 
6 unit16_t ntohs (unit16_t netshort); 
7
8 说明:h ‐‐‐‐‐host;n‐‐‐‐network ;s‐‐‐‐‐‐short;l‐‐‐‐long

31、内存泄漏

程序没有释放已经不再使用的内存,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,因此这段内存一直被占用,无法释放,造成空间的浪费。

内存泄漏指的是开辟的内存没有释放,或者是存在用户操作的错误,导致野指针,无法释放原来分配的内存

如何避免:
排查方法:
1、malloc free函数调用后,立即打印地址,比对是否有漏的
2. 借助第三方工具valgrind。 提一下常用的用法就行。

32、内存溢出(堆溢出)

要求分配的内存超过了系统能给我的,系统不能满足需求。
内存泄漏的堆积如果不及时处理最终会导致内存溢出

33、指令

注意
#表示用户是超级管理员用户
$ 表示普通用户

1、ls

ls

含义:列出当前工作目录下的所有文件/文件夹的名称

ls 路径

含义:列出指定路径下的所有文件/文件夹的名称

关于路径(重要):
路径可以分为两种:相对路径、绝对路径。
相对路径:相对首先得有一个参照物(一般就是当前的工作路径);

相对路径:在相对路径中通常会用到2个符号“./”【表示当前目录下】、“…/”【上一级目录下】。

绝对路径:绝对路径不需要参照物,直接从根“/”开始寻找对应路径;

ls 选项 路径

含义:在列出指定路径下的文件/文件夹的名称,并以指定的格式进行显示。
常见的语法:
#ls -l 路径
#ls -la 路径

选项解释:
-l:表示list,表示以详细列表的形式进行展示,列表中的第一列字符表示文档的类型,其中“-”表示该行对应的文档类型为文件,“d”表示文档类型为文件夹。

-a:表示显示所有的文件/文件夹(包含了隐藏文件/文件夹),在Linux中隐藏文档一般都是以“.”开头。

ls -lh 路径

含义:列出指定路径下的所有文件/文件夹的名称,以列表的形式并且在显示文档大小的时候以可读性较高的形式显示,注意单位不是固定的,他会以最合适的进行显示。

注意,如果都要显示也可以一起用,后面的顺序没什么要求, ls -lah /root

2、pwd 指令

用法:#pwd (print working directory,打印当前工作目录)

3、cd 指令

命令:#cd (change directory,改变目录)
作用:用于切换当前的工作目录的
语法:#cd 路径

当前在“/”下,需要使用绝对路径切换到/usr/local
cd /usr/local

当前在/usr/local下,需要使用相对路径切换目录到home目录下的 wq 用户家目录中去。
cd …/…/home/wq

补充:
在Linux中有一个特殊的符号“~”,表示当前用户的家目录。
切换的方式:#cd ~

4、mkdir 指令

指令:mkdir (make directory,创建目录)

mkdir 路径

【路径,可以是文件夹名称也可以是包含名称的一个完整路径】

案例:在当前路径下创建出目录“yunweihenniux”
mkdir yunweihenniux

mkdir -p 路径

含义:当一次性创建多层不存在的目录的时候,添加-p参数,否则会报错
mkdir -p yu/a/b

注意yu前面没有/的话表示在当前目录下创建
有/的话表示在跟目录下创建
mkdir -p /yu/a/b

mkdir 路径1 路径2 路径3 ….

【表示一次性创建多个目录】

5、touch指令

指令:touch
作用:创建文件
语法:#touch 文件路径 【路径可以是直接的文件名也可以是路径】

案例:

  1. 使用touch来在当前路径下创建一个文件,命名为Linux.txt
    touch linix.txt

  2. 使用touch来同时创建多个文件
    touch linux1.txt linux2.txt

  3. 使用touch来在“wq”用户的家目录中创建文件,Linux.txt
    touch /home/wq/linux.txt

6、cp指令

指令:cp (copy,复制)
作用:复制文件/文件夹到指定的位置
语法:#cp 被复制的文档路径 文档被复制到的路径

案例:

  1. 使用cp命令来复制一个文件
    cp linux1.txt /home/wq/linux3.txt
    注意:Linux在复制过程中是可以重新对新位置的文件进行重命名的,但是如果不是必须的需要,则建议保持前后名称一致。

  2. 使用cp命令来复制一个文件夹
    注意:当使用cp命令进行文件夹复制操作的时候需要添加选项“-r”【-r表示递归复制】,否则目录将被忽略
    在这里插入图片描述

7、mv指令

指令:mv (move,移动,剪切)
作用:移动文档到新的位置
语法:#mv 需要移动的文档路径 需要保存的位置路径

确认:移动之后原始的文件还在不在原来的位置?原始文件是不在原始位置的

案例:使用mv命令移动一个文件
mv linix.txt /linix.txt

案例:使用mv命令移动一个文件夹
mv /home/wq/yun /

补充:在Linux中重命名的命令也是mv,语法和移动语法一样。
mv /yun /xiugaiyun

8、rm指令

指令:rm (remove,移除、删除)
作用:移除/删除文档
语法:#rm 选项 需要移除的文档路径
选项:
-f:force,强制删除,不提示是否删除
-r:表示递归

案例:删除一个文件

学习链接:

https://blog.csdn.net/weixin_30357231/article/details/99587598

gcc程序的编译过程和链接原理

GCC编译过程
动态链接库和静态链接库
在这里插入图片描述

C/C++文件的编译过程:
一个C/C++文件要经过预处理(preprocessing)、编译(compilation)、汇编(assembly)、和连接(linking)才能变成可执行文件。

先来看一下gcc的使用方法和常用选项
提示:gcc --help

Ⅰ、使用方法:
gcc [选项] 文件名

Ⅱ、常用选项:

选项含义
-v查看gcc编译器的版本,显示gcc执行时的详细过程
-o Place the output into ;指定输出文件名为file,这个名称不能跟源文件名同名
-EPreprocess only; do not compile, assemble or link;只预处理,不会编译、汇编、链接
-SCompile only; do not assemble or link;只编译,不会汇编、链接
-cCompile and assemble, but do not link; 编译和汇编,不会链接

在这里插入图片描述
4. GCC链接库文件的使用
在 linux 下开发软件时,完全不使用第三方函数库的情况是比较少见的,通常来讲都需要借助一个或多个函数库的支持才能够完成相应的功能。从程序员的角度看,函数库实际上就是一些头文件(.h)和库文件(.so或者.a)的集合。虽然 Linux 下的大多数函数都默认将头文件放到/usr/include/目录下,而库文件则放到/usr/lib/目录下,但并不是所有的情况都是这样。正因如此, GCC 在编译时必须有自己的办法来查找所需要的头文件和库文件, GCC 采用搜索目录的办法来查找所需要的文件。

4.1 添加头文件
-I 选项可以向 GCC 的头文件搜索路径中添加新的目录。例如,如果在/home/justin/include/目录下有编译时所需要的头文件,为了让 GCC 能够顺利地找到它们,就可以使用-I选项:

gcc foo.c -I /home/justin/include -o foo    # 指定头文件目录

4.2 添加库文件
同样,如果使用了不在标准位置的库文件,那么可以通过 -L 选项向 GCC 的库文件搜索路径中添加新的目录。例如,如果在/home/xiaowp/lib/目录下有链接时所需要的库文件libfoo.so,为了让 GCC 能够顺利地找到它,可以使用下面的命令:

gcc foo.c -L /home/justin/lib -lfoo -o foo   # 指定库目录和库名称

值得好好解释一下的是 -l 选项,它指示 GCC 去连接库文件 libfoo.so。 Linux 下的库文件在命名时有一个约定,那就是应该以lib 三个字母开头,由于所有的库文件都遵循了同样的规范,因此在用 -l 选项指定链接的库文件名时可以省去lib 三个字母,也就是说GCC 在对-lfoo进行处理时,会自动去链接名为libfoo.so 。

4.3 动态库与静态库
Linux 下的库文件分为两大类分别是动态链接库(通常以 .so 结尾)和静态链接库(通常以 .a 结尾),两者的差别仅在程序执行时所需的代码是在运行时动态加载的,还是在编译时静态加载的 。默认情况下,GCC 在链接时优先使用动态链接库,只有当动态链接库不存在时才考虑使用静态链接库,如果需要的话可以在编译时加上 -static 选项,强制使用静态链接库。例如,如果在home/justin/lib/ 目录下有链接时所需要的库文件libfoo.so 和libfoo.a,为了让GCC 在链接时只用到静态链接库,可以使用下面的命令:

gcc foo.c -L /home/justin/lib -static -lfoo -o foo

静态库

之所以成为【静态库】,是因为在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。

试想一下,静态库与汇编生成的目标文件一起链接为可执行文件,那么静态库必定跟.o文件格式相似。其实一个静态库可以简单看成是一组目标文件(.o/.obj文件)的集合,即很多目标文件经过压缩打包后形成的一个文件。静态库特点总结:

静态库对函数库的链接是放在编译时期完成的。
程序在运行时与函数库再无瓜葛,移植方便。
浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件。
Linux下创建与使用静态库
Linux静态库命名规则
Linux静态库命名规范,必须是"lib[your_library_name].a":lib为前缀,中间是静态库名,扩展名为.a。

创建静态库(.a)
通过上面的流程可以知道,Linux创建静态库过程如下:

l 首先,将代码文件编译成目标文件.o(StaticMath.o)

g++ -c StaticMath.cpp
注意带参数-c,否则直接编译为可执行文件

然后,通过ar工具将目标文件打包成.a静态库文件

ar -crv libstaticmath.a StaticMath.o
生成静态库libstaticmath.a。

一般是不会把源代码给第三方的,通常把生成的可执行文件(.o文件)打包成库文件。
直接使用-c,
在这里插入图片描述
在这里插入图片描述

linux的nm命令可以获取可执行文件里的符号表。
在这里插入图片描述
静态库
在这里插入图片描述
静态库的优点:

  1. 发布程序的时候,不需要提供对应的库
  2. 加载库的速度快

缺点:
3. 库被打包到应用程序中,导致库的体积很大
4. 库发生了改变,需要重新编译程序

动态库

制作动态库
在这里插入图片描述
编写一个main.c,使用动态库来执行的两种方式
在这里插入图片描述

解决动态库连接失败的问题

  1. 使用场景:在开发测试时使用
    将lib临时地添加到系统环境变量中去,(注意,关机重启后就失效了)
    在这里插入图片描述
    重启后又连接不到了
    在这里插入图片描述

  2. 修改配置文件 .bashrc
    不常用的方法(永久设置):
    在家目录的 .bashrc文件 中添加一句话: export LD_LIBRARY_PATH=动态库目录的绝对路径
    .bashrc修改完成, 需要重启终端

在这里插入图片描述
在这里插入图片描述

  1. 最重要的方法
    1. 需要找动态连接器的配置文件 – /etc/ld.so.conf
    2. 动态库的路径写到配置文件中 – 绝对路径
    3. 更新 – sudo ldconfig -v

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
动态库是在执行起来时才会被加载到内存中,所以打包时不会把动态库打包到应用程序里

动态库的优点
1.执行程序体积小
2.动态库更新了,不需要重新编译程序,因为函数接口不变
缺点:
1.发布程序的时候,需要将动态库提供给用户
2.动态库没有被打包到应用程序中,加载速度相对较慢

vi 分屏

打开文件,在末行模式下输入:sp,即可上下分屏,然后按 ctr+ww,实现两个文件间的切换
光标在哪个文件,:q,wq就对该文件操作,如果要关所有的就:wqall

输入:vsp,实现左右分屏
:vsp hello.c 分屏打开这个文件

linux编译程序过程 gdb调试

gcc
在这里插入图片描述

循环创建n个子进程

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
        int i;
        pid_t pid;
        printf("xxxxxxxx\n");
        for(i=0;i<5;i++){
                pid = fork();
                if (pid == -1) {
                        perror("fork error:");
                        exit(1);
                }else if(pid==0){
                        break;
                }
        }
        if(i<5){
                sleep(i); 
                printf("I'am %d_ child ,pid = %u\n",i+1, getpid());
        } else {
                sleep(i);
                printf("I'm parent\n");
       }
}

在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值