计算机操作系统


操作系统面试(持续更新)

1.请说一下源码到可执行文件的过程

1)预编译
主要处理源代码文件中以”#”开头的预编译指令。处理规则如下
1.删除所有的#define,展开所有的宏定义
2.处理所有的条件预编译指令
如”#if”,”#endif”,”#ifdef”,”#elif”和”#else”
3.处理”#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件
4.删除所有注释,”//“ 和” ** “
5.保留所有的”#pragma” 编译器指令,编译器需要用到他们。如”#pragma once”是为了防止有文件被重复引用
6.添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译是产生的编译错误或警告能够显示行号。

2)编译
把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析,语法分析,语法分析及优化后,生成相对应的汇编代码文件。

1.词法分析:
将源代码程序导入到扫描机道中,将其中的字符序列分割成一系列的记号。
2.语法分析:
语法分析器对由扫描产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为结点的树。 在语法分析的同时,就把运算法优先级确定下来,如果出现表达式不合法,各种括号不匹配,表达式中缺少操作,编译器报错。
3.语义分析:
语法分析器只是完成对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义–在编译期能分清的语义,相对应的动态语义是在运行期间才能确定的语义。例如:将一个int型赋值给int * 型,语义分析程序就发现类型不匹配,就会报错。
4.优化:
源代码级别的一个优化过程。
5.目标代码生成:
由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列–汇编语言表示。
6.目标代码优化:
目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式,使用位移来替代乘法运算,删除多余的指令等。

3)汇编
将汇编代码转换成机器可以执行的指令(机器码文件)。汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as完成。经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(windows),xxx.obj(linux)

4)链接
链接的过程:地址和空间的分配,符号决议(也叫做符号绑定,倾向于动态链接)和重定位。将不同的源文件产生的目标文件进行链接,从而形成一个可执行的程序。链接分为静态链接和动态链接。

1.静态链接:
函数和数据被编译进一个二进制文件。在静态库的情况下,在编译链接可执行文件时,链接器
从库中复制这些函数和数据并把他们和应用程序的其他模块组合起来创建最终的可执行文件。
空间浪费:
因为每个可执行程序中对所有需要的目标文件都要有一个副本,所以如果多个程序对同一个目标文件都有依赖的时候,会出现同一个目标文件都在内存存在多个副本。
更新困难:
每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
运行速度快:
但是静态链接的优点就是,在可执行程序中已经具备所有执行程序所需要的任何东西,在执行的时候运行速度快。

2.动态链接
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将他们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。动态库就是在需要调用其中的函数时,根据函数映射表找到该函数,然后调入堆栈执行。
共享库:
就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分,副本,而是这多个程序在执行时共享同一份副本。
更新方便:
更新时只需要替换原来的目标文件,而无需将所有的程序重新链接一遍。当程序下一次运行时,新版本的目标文件会自动加载到内存并且链接起来,程序就完成了升级的目标。
性能损耗:
因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定的损失。

2 进程与线程的概念

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

3 为什么要有进程线程,其中有什么区别

进程是指在系统中正在运行的一个应用程序,程序一旦运行起来就是进程。进程是系统资源分配的最小单位,且每个进程拥有独立的地址空间,实现了操作系统的并发。线程是进程的子任务,是CPU最小执行和调度的基本单位,实现了进程内部的并发。

线程是进程的一个实体,是进程的一条执行路径,比进程更小的独立运行的基本单位,线程也被称为轻量级进程,一个程序至少有一个进程,一个进程至少有一个线程。

每个线程都独自占用一个虚拟处理器,独自的寄存器组,指令计数器和处理器状态。每个线程完成不同的任务但是共享同一地址空间(也就是同样的动态内存,映射文件和目标代码等),打开的文件队列和其他内核资源。

为什么要有线程?

进程在执行的过程中如果阻塞,整个进程就会挂起,即使进程中有些工作不依赖于等待的资源,仍然不会执行,单进程各个函数之间不是并发执行,影响资源的使用效率,利用多进程解决维护进程系统开销大,创建进程时,分配资源,建立PCB,进程撤销时,回收资源,撤销PCB,进程切换,保存当前进程的状态信息。从通信机制上来讲,线程间有方便的通信机制。

区别:
1.一个线程只能属于一个进程,而一个进程可以有多个线程,但至少由一个线程。线程依赖进程而存在。每个独立的进程有一个程序的入口,程序出口。但线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
2.进程是资源分配的最小单位,线程是CPU调度的最小单位。
3.进程在执行过程中拥有独立的地址空间,而多个线程共享进程的资源。(同一个进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。
4.系统开销:
由于在创建或者撤销进程时,系统都要为之分配或回收资源,如内存空间,I/O设备,PCB程序控制块等。因此操作系统所付出的开销将显著的大于在创建或撤销线程时的开销。类似的,在进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只需保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。可见,进程切换的开销也远大于线程切换的开销。
5.进程间不会相互影响,线程一个线程挂掉将导致整个进程挂掉。
6.通信:
由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。线程间可以直接读写进程数据段(如全局变量)来进行通信–需要进程同步和互斥手段的辅助,以保证数据的一致性。进程间通信主要包括管道,系统IPC(包括消息队列,信号量,信号,共享内存),以及套接字socket.

4.什么是信号量

信号量(semaphore)与已经介绍过的IPC结构不同,它是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥和同步,而不是用于存储进程间通信数据。
特点:
1)信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
2)信号量基于操作系统的PV操作,程序对信号量的操作都是原子操作。抽象数据类型,信号量。

一个整形(semaphore),两个原子操作,进入临界区之前执行P操作,离开临界区执行V操作。
P():sem-1 如果sem<0 等待,否则继续
V():sem+1 如果sem<=0就代表有进程在等待,唤醒一个挂在信号量上等待的P,FIFO原则
信号量是一个整数,被保护,只有PV操作能改变值

3)每次对信号量的PV操作不仅限于对信号值加1或减1,而是可以加减任意正整数。

class semaphore{
    int sem;
    //等待队列,sem<0 线程被存在等待队列
    waitQueue q;
}
semaphore::P(){
    sem--;his 
    if(sem<0){
        Add this thread t to q
        //线程放入等待队列
        block(p);
    }
}
semaphore::V(){
    sem++;
    if(sem<=0){
        Remove a thread t from q
        //唤醒一个在等待的线程
        wakeup(t);
    }
}

其系统调用为
sem_wait(sem_t * sem):以原子操作的方式将信号量减1,如果信号量值为0,则sem_wait被阻塞,直到信号量具有非0值。
sem_post(sem_t * sem):以原子操作将信号量加1.当信号量大于0时,其他正在调用sem_wait等待信号量的线程将被唤醒。

5.线程通信的方式

临界区:
通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。
互斥量Synchronized/Lock:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。当进入临界区时,需要获得互斥锁并且加锁;那么其他线程先要访问临界区就需要等待锁的释放。当离开临界区时,需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程。
锁时一个抽象的数据结构
一个二进制状态(解锁/绑定)
Lock::Acquire() 锁被释放之前一直等待,然后得到锁
Lock::release() 释放锁,唤醒任何等待的进程
怎么实现:
原子操作
test-and-set
从内存中读值
测试该值是否为1,不为1进入临界区
内存值设为1
其主要的系统调用如下:
pthread_mutex_init:初始化互斥锁
pthread_mutex_destroy:销毁互斥锁
pthread_mutex_lock:以原子操作的方式给一个互斥锁加锁,如果目标互斥锁已经被上锁,pthread_mutex_lock调用将其阻塞,直到该互斥锁的占有者将其解锁
pthread_mutex_unlock:以一个原子操作的方式给一个互斥锁解锁

信号量semphore:为控制具有有限数量的用户资源而设计的,它允许多个线程在同一时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目
条件变量,顾名思义,一个或多个线程等待某个布尔表达式为真,即等待别的线程唤醒它.信号量会与互斥量一起使用,条件本身是由互斥量保护的。线程在改变条件状态前必须锁住互斥量。
其主要的系统调用如下:
pthread_cond_init:初始化条件变量
pthread_cond_destroy:销毁条件变量
pthread_cond_signal: 唤醒一个等待目标条件变量的线程。哪个线程被唤醒取决于调度策略和优先级。
pthread_cond_wait:等待目标条件变量。需要一个加锁的互斥锁确保操作的原子性。该函数中在进入wait状态前首先进行解锁,然后接收到信号后会再加锁,保证该线程对共享资源的正确访问。

//条件变量实现
//需要维持在每个条件队列
//线性等待的条件等待signal()
class Condition(){
    //等待的线程数
    int numwaiting = 0;
    waitQueue q;
}
Condition::Wait(lock){
    numwaiting++;
    Add this thread t to q
    //一定要先释放锁
    release(lock);
    schedule();//需要互斥锁,阻塞再这里,等待信号量满足条件
    require(lock);
}
Condition::Signal(){
    //和信号量不一样,不一定执行--操作
    if(numwaiting > 0){
        remove a thread from q
        wakeup(t);//需要互斥锁
        numwaiting --;

    }
}
//从管程看生产者消费者问题
class BoundBuffer{
    //保证互斥
    Lock lock;
    //buffer 内容计数
    int count = 0;
    //两个条件变量
    Condition notFull,not Empty;

}
BoundBuffer::Deposit(c){
    //线程进入管程,只有一个线程能进去
    lock->Acquire();
    while(count == n)
        //当前已经满了,睡眠,在wait中一定要释放互斥锁
        notFull.wait(&lock);
    Add c to Buffer;
    count++;
    notEmpty.Signal();
    lock->release;

}
BoundBuffer::Remove(c){
    lock->Acquire();
    while(count == 0){
        notEmpty.wait(&lock);

    }
    remove c from buffer;
    count--;
    //有空闲了,唤醒notFull中的线程
    notFull.Signal();
    lock->release();
}

6.虚拟内存?为什么要有虚拟内存?什么是虚拟地址空间?

虚拟内存。虚拟内存是一种内存管理技术,它会使程序自己认为自己拥有一块很大且连续的内存,然而这个程序在内存中不是连续的,并且有些还在磁盘上,在需要的时候进行数据交换。
为什么要有虚拟内存?
在早期的计算机中,是没有虚拟内存的概念的。我们运行一个程序,会把程序全部装入内存中,然后运行。
当运行多个程序的时候,经常会出现如下的问题:
地址空间不隔离,没有权限保护。
由于程序都是直接访问物理内存,所以一个进程可以修改其他进程的内存数据,甚至修改内核地址空间里的数据。
内存使用效率低
当内存空间不足的时候,将其他程序暂时拷贝到硬盘,然后新程序装入内存运行。由于大量的数据装入装出,内存效率十分低下。
程序运行地址不稳定
因为程序内存地址都是随机分配的,所以程序运行的地址也是不确定的,内存管理复杂。
虚拟地址的优点:
避免用户直接访问物理地址,防止一些破坏性操作,保护操作系统。
每个进程都分配4GB的虚拟内存,用户程序可使用比实际物理内存更大的地址空间。
当进程通信时,可采用虚存共享的方式实现。当不同的进程使用相同的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码,不同的进程只需要把自己的虚拟内存映射过去就可以了。 简化了链接器,加载器这样的程序的内存管理。
节省内存。
什么是虚拟空间?
虚拟地址空间是对于一个单一进程的概念,这个进程看到的将是从地址0000开始的整个内存空间 。虚拟存储器是一个抽象概念,它为每个进程提供了一个假象,好像每一个进程都在独占地使用主存。每个进程看到的存储器都是一致的,称为虚拟地址空间。从最低的地址看起:程序代码和数据,堆,共享库,栈,内存虚拟存储器。

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

7.mmap原理

mmap是用来建立从虚拟空间到磁盘空间的映射的,可以将一个虚拟空间地址映射到一个磁盘文件上,当不设置这个地址时,则由系统自动设置,函数返回对应的内存地址(虚拟地址)。使用mmap分配内存,在堆和栈之间找一对空闲的内存分配.这两种方式分配的是虚拟内存,没有分配物理内存。真正的分配要在第一次访问已经分配的虚拟内存空间的时候,发生缺页中断,由操作系统分配物理内存,建立虚拟内存和物理内存之间的映射关系。

8.进程控制块包含信息和组织方式

进程控制块(PCB)是系统为了管理进程设置的一个专门的数据结构。系统用它来记录进程的外部特征,进程描述的运动变化过程。同时,系统可以利用PCB来控制和管理进程。所以说,PCB是系统感知进程存在的唯一标志。
PCB含有以下三大类信息:
进程标识信息:本进程标识,父进程标识,用户标识
处理机状态信息保存区:
保存进程的运行现场信息。用户可见的寄存器,控制和状态寄存器,栈指针(过程调用,系统调用,中断处理和返回需要用到)
进程控制信息:
调度和状态信息:如进程状态,等待事件和等待原因,进程优先级
进程通信信息:如消息队列指针,信号量等互斥和同步机制,这些信息存放在接收方的进程块中。
存储管理信息:进程在辅存储器内的地址,包含指向本进程映像存储空间的数据结构。
进程所用资源:包含进程所需全部资源,已经分得的资源,如主存资源,I/O设备,打开文件表等。
有关数据结构连接信息:父子进程连接起来,进程可以连接到一个进程队列中,或连接到相关的其他进程PCB。
PCB组织方式
一般是链表:更好的完成动态插入和删除。一个状态的进程对应PCB中的一个链表,如就绪链表,阻塞链表。

9.malloc,realloc,calloc的区别

1.malloc函数

void * malloc(unsigned int num_size);
int * p = (int *)malloc(20*sizeof(int));

申请20个int类型的空间
2.calloc函数

void * calloc(size_t,size_t size);
int * p = calloc(20,sizeof(int));

malloc的申请空间是随机化的,callloc的申请的空间的值初始化为0
3.realloc 函数

void realloc(void * p,size_t new_size);

给分配的额外分配空间,用于扩充容量。
这里有几个问题:
1.堆和栈最大可分配的内存的大小
2.堆和栈的内存管理方式
3.堆和栈的分配效率
首先针对第一个问题,一般来说对于一个进程栈的大小远远小于堆的大小。在linux中你可以使用ulimit-s(单位kb)来查看一个进程栈的最大可分配大小,一般来说不超过8M,有的甚至不超过2M,不过这个可以设置,而对于堆你会发现,针对一个进程堆的最大可分配的大小在G的数量级上,不同的操作系统可能不一样,比如32位系统最大不超过2G,而64位系统最大不超过4G,所以当你需要一个分配的大小的内存时,请用new,即用堆。

其次针对第二个问题,栈是系统数据结构,对于进程/线程是唯一的,它的分配与释放由操作系统来维护,不需要开发者来管理。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时,这些存储单元会被自动释放。栈内存分配运算内置于处理器执行集中,效率很高。不同的操作系统对栈都有一定的限制。堆上的内存分配,也成为动态内存分配 .程序在运行的期间用malloc申请的内存,这部分内存由程序员自己负责管理,其生存期由开发者决定:在何时分配,分配多少,并在何时用free来释放该内存。这是唯一可以由开发者参与管理的内存。使用的好坏直接决定系统的性能和稳定。

由上可知,当我们需要的内存很少,又能确定到底需要多少内存的时候,请使用栈。而当你需要在运行时才知道需要多少内存的时候,请用堆。

最后针对第三个问题,栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放在栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆是C/C++函数提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构、操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(内存碎片过多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率要比栈要低的多。

10.并行和并发

并发(concurrency):指宏观上看起来两个程序同时运行,比如在单核cpu上的多任务。但是微观上看两个程序的指令是交织着运行的。你的指令之间穿插这我的指令,我的指令穿插这你的,在单个周期内只运行了一个指令。这种并发不能提高计算机的性能,只能提高效率。

并行(parallelism):指严格物理意义上统一时刻的同时运行,比如多核cpu,两个程序分别运行在两个核上,两者之间相互不影响。这样说来并行的确提高了计算机的效率,所以现在的cpu都是往多核方面发展的。

11.如何修改文件的最大句柄数

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

有两种方法:
1.ulimit-n <可以同时打开的文件数> 。将当前进程的最大句柄数修改为指定的参数(注意:该方法只针对当前进程有效,重新打开shell或者重新开启一个进程,参数还是之前的值)
2.对所有进程都有效的方法,修改linux系统参数
vi/etc/security/limits.conf添加

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

12.进程加载和终止调用的函数?fork和vfork的区别

fork()创建一个继承的子进程
当调用fork时,内核会把所有的内部数据结构复制一份,复制进程的页表项,然后把父进程的地址空间中的内容逐页的复制到子进程的地址空间中但从多核角度来说,逐页的复制方式是十分耗时的。

父进程返回子进程的id,子进程返回0 不成功id<0
复制父进程所有的变量和内存
复制父进程所有的cpu寄存器

int pid = fork();
if(pid == 0){
    //做什么都行(关闭网络连接)
    //调用exec()加载新程序取代当前运行进程,地址空间,代码数据也换掉了。
    exec("program",argc,argv0,argv1...)
}
//等待子进程结束child_status = wait(pid);
}

在fork()后立刻执行exec所造成的地址空间的浪费。vfork()轻量级fork,创建进程时不再创建同样的内存映像

子进程必须要立刻执行一次对exec的系统调用,或者调用_exit()退出,vfork()会挂起父进程直到子进程终止或者运行了一个新的可执行文件的映像。通过这样的方式,vfork()避免了地址空间的按页复制。
**
COPY On Write (写的时候进行复制),在实际地址空间复制的时候并没有真实的复制,而是复制父进程地址空间所需要的元数据(页表),指向同一块地址空间,当父进程或者子进程对某一个地址单元写操作会触发一个异常,使得触发异常的页复制成两份。只有写的时候才会。**

fork和vfork的区别
fork()的父子进程的执行次序不确定。vfork()保证子进程先运行在调用exec或exit僵尸与父进程数据是共享的,在它调用exec或exit之后父进程才可能被调度运行

13.孤儿进程 & 僵尸进程

孤儿进程
父进程运行结束,但子进程还在运行(未运行结束),这样的子进程为孤儿进程。
每当出现一个孤儿进程,内核就把孤儿进程的父进程设置为init,而init进程会循环的wait()它的已经退出的子进程。这样当一个孤儿进程凄凉的结束了其生命周期的时候,init进程就会代表父进程回收。孤儿进程不会有什么危害。

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

14.管道的读写特点

使用管道时需要注意以下几种特殊的情况(假设都是阻塞的I/O操作)
1.所有的指向管道写端的文件描述符都关闭了(管道写端引用计数为0),进程从管道的读端读数据,那么管道中剩余的数据被读取以后,再次read会返回0.就像读到文件末尾一样。

2.如果有指向管道写端的文件描述符没有关闭(管道的写端引用计数大于0),而持有管道写端的进程也没有往管道中写数据 ,这个时候有进程从管道中读取数据,那么管道中剩余的数据被读取后再次read会阻塞,直到管道中有数据可读,才读取数据并返回。

3.如果所有指向管道读端的文件描述符都关闭了(管道的读端引用计数为0),这个时候有进程向管道中写数据,那么该进程会收到一个信号SIGPIPE,通常会导致进程异常终止。

4.如果有指向管道读端的文件描述符没有关闭,(管道的读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写数据,那么在管道被写满的时候,再次write会被阻塞,直到管道中有空位置才能写入数据并返回。

总结:
读管道:
管道中有数据,read返回实际读到的字节数
管道中无数据:
写端全部关闭,read返回0 (相当于读到文件的末尾)
写端没有完全关闭,read阻塞等待。
写管道:
管道读端全部被关闭,进程异常终止(进程收到SIFPIPE信号)
管道读端没有完全关闭:
管道已满,write阻塞
管道没有满,write将数据写入并返回实际写入的字节数。

15.常见I/O模型?5种?异步IO应用场景?有什么缺点

几个概念 :同步,异步,阻塞,非阻塞
首先来解释同步和异步的概念,这两个概念与消息通知机制有关。也就是同步与异步主要是从消息通知机制角度来说的。
同步异步概念
所谓同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么成功都成功,要么失败都失败,两个任务的状态可以保持一致。

所谓异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定。所以它是不可靠的任务序列

1)同步
就是再发出一个功能调用时,没有得到结果之前,该调用就不返回,同步I/O指的是,必须等待IO操作完成后,控制权才返回给用户进程。也就是必须一件一件事情做,等前一件事情做完了才能做下一件事情。就是我们调用一个功能,该功能没有结束前,我死等结果。

2)异步
当一个异步调用发出后,调用者不能立即得到结果。实际处理这个调用的部件在完成后,通过状态,通知和调用来通知调用者。我调用一个功能,不知道该功能的结果,该功能有结果后通知我(回调通知)

阻塞与非阻塞
阻塞和非阻塞这两个概念与程序(线程)等待消息通知(无所谓同步或者异步)时的状态有关。也就是说阻塞与非阻塞主要是程序(线程)等待消息通知时的状态角度来说的

3)阻塞
阻塞调用是指调用结果返回前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之前才会返回。对于同步调用来说,很多时候当前进程还是激活的,只是逻辑上当前函数没有返回而已。就是调用函数,函数在没有接收完数据或者没有得到结果之前不会返回。

4)非阻塞
指在不能立刻得到结果之前,该函数不会阻塞当前线程,而是会立刻返回

5种常见IO模型
1.阻塞IO
应用程序调用一个IO函数,导致程序阻塞,等待数据准备好,这个时候有进程从管道中读取数据,那么管道中剩余的数据被读取后再次read会阻塞,直到管道中有数据可读,才读取数据并返回。
2.非阻塞IO
我们把一个SOCKET接口设置为非阻塞就是告诉内核,当请求的I/O操作无法完成时,不要将进程睡眠,而是返回一个错误,这样我们的IO操作函数将不断的测试数据是否已经准备好,如果没有准备好,继续测试,直到数据准备好为止。在这个不断测试的过程中,会大量的占用CPU的时间。
3.IO复用模型会用到select,poll,epoll函数。这几个函数也会使进程阻塞,但是和阻塞IO不同的是,这三个函数可以同时阻塞多个IO操作。而且可以同时对多个读操作,多个写操作的IO函数进行检测,直到有数据可读或可写时,才真正调用IO函数。
4.信号驱动
开启套接字的信号驱动式IO功能。通过sigaction系统调用安装一个信号处理函数,该系统调用让内核在描述符就绪时发送SIGIO信号通知我们,在信号处理函数中调用IO操作函数处理数据。
5.异步IO模型
当一个异步过程调用发出后,调用者不能立刻得到结果。,实际处理这个调用的部件在完成后,通过状态,通知,回调来通知调用者的输入输出操作。与信号驱动IO的主要区别,信号驱动IO主要是通知我们何时开启一个IO操作,而异步IO模型是由内核通知我们IO操作何时完成。

16.进程控制块包含信息和组织方式

进程控制块(PCB)是系统为了管理进程设置的一个专门的数据结构。系统用它来记录进程的外部特征,描述进程的运动变化过程。同时,系统可以利用PCB来控制和管理进程,所以说PCB是系统感知进程存在的唯一标志。

PCB含有以下三大类信息:
进程标识信息:本进程标识,父进程标识,用户标识
处理机状态信息保存区:
保存进程的运行现场信息,用户可见到的寄存器,控制和状态寄存器,栈指针(过程调用,系统调用,中断处理和返回需要用到)

进程控制信息:
调度和状态信息:如进程状态,等待事件和等待原因,进程优先级,进程通信信息:如消息队列指针,信号量等互斥和同步机制,这些信息存放在接收方的进程控制块中。存储管理信息:进程在辅存储器内的地址,包含指向本进程映像存储空间的数据结构。进程所用资源:包括进程所需全部资源,已经分得的资源,如主存资源,IO设备,打开文件表等。有关数据结构连接信息:父子进程连接起来,进程可以连接到一个进程队列中,或连接到相关的其他进程PCB。
PCB组织方式
一般是链表,更好的完成动态插入删除。一个状态的进程对应PCB中的一个链表。如就绪链表,阻塞链表。

17.请说一说操作系统中的程序的内存结构

可以看到一个可执行程序存储在(没有调入内存)时分为代码段,数据区和未初始化数据区三部分。
BSS段(未初始化数据区):通常用来存放程序中未初始化的全局变量和静态变量的一块内存区域。BSS段属于静态分配,程序结束后静态变量资源由系统自动释放。
数据段:存放程序中已初始化的全局变量的一块内存区域。数据段也属于静态内存分配

代码段:存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域属于只读。在代码段中也有可能包含一些只读的常数变量。
text段和data段在编译时已经分配了空间,而BSS段并不占用可执行文件的大小,它是由链接器来获取内存的。

bss段(未进行初始化的数据)的内容并不存放在磁盘上的程序文件中,其原因是在内核在程序开始运行前将他们置为0.需要存放在程序文件中的只有正文段和初始化数据段。BSS的大小从可执行文件中得到,然后链接器得到这个大小的内存块,紧跟在数据段的后面,当这个内存进入程序的地址空间后全部清零。包含数据段和BSS段的整个区段此时称之为数据区。
可执行程序在运行时又多出两个区域:栈区和堆区

栈区:由编译器自动释放,存放函数的参数值,局部变量等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存放在栈中。然后这个被调用的函数再为他的自动变量和临时变量在栈上分配空间。每调用一个函数一个新的栈就会被使用。栈区是从高地址为向低地址位增长的,是一个连续的内存区域,最大容量是由系统预先定义好的,申请的占空间超过这个界限就会提示溢出,用户能从栈中获取的空间较小。

堆区:用于动态分配内存,位于BSS和栈中间的的地址区域。由程序员申请分配和释放。 堆是从低地址位向高地址位增长,采用链式存储结构。频繁的malloc、free造成内存空间的不连续,产生碎片。当申请堆空间时,库函数是按照一定的算法搜索可用的足够大的空间,因此堆的效率要比栈低得多。

18.虚拟内存的技术实现

虚拟内存的实现需要加你了在离散分配的内存管理方式的基础上,虚拟内存的实现有以下三种方式:
1.请求分页存储管理:建立在基本分页系统的基础上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。
2.请求分段存储管理
3.请求段页式存储管理
不管是上面哪种实现方式,我们一般需要:
1.一定容量的内存和外存,在载入程序的时候,只需要将程序的一部分装入内存而将其他部分留在外存,然后程序就可以执行了。
2.缺页中断:如果需要执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入内存,然后执行程序。
3.虚拟地址空间:逻辑地址到物理地址的变换。

19.缺页步骤

缺页是指当CPU请求一个虚拟地址时,虚拟地址所对应的页在物理内存中不存在。此时MMU会产生缺页错误,然后由内核的缺页处理程序从磁盘中调入对应的页到主存中。在处理完成后,CPU会重新执行导致错误的指令,从而读取到对应的内存数据。
下面时缺页时的地址翻译的过程(第一步到第三步与页命中时相同)
1.处理器生成一个虚拟地址,并把它传送给MMU
2.MMU生成根据地址生成VPN,然后请求高速缓存、主存,获取PTE的数据。
3.高速缓存,主存向MMU返回PTE数据
4.由于判断出PTE有效位是0,所以CPU将发出一次异常,将控制权转移给内核中的缺页异常处理程序。
5.缺页或异常处理程序确定出物理内存中的牺牲页,如果这个页面被修改过了(D标志位位1),那么将牺牲页换出到磁盘。
6.缺页处理程序从磁盘中调入新的页面到主存,并且更新PTE
7.缺页处理程序将控制权返回给原来的进程。再次执行导致缺页的指令。再次执行后,就会产生页命中时的情况了。

20.页面置换算法

上文提到缺页,但是当内存满了的时候,就需要从内存中按照一定的置换算法决定内存把哪个页面放弃,存入新的页
最佳置换算法OPT
算法思想:每次选择淘汰的页面将是以后永不使用,或者最长时间内不再被访问的页面。这样可以保证最低的缺页率。最佳置换算法可以保证最低的缺页率,但是实际上,只有进程执行的过程中才能知道接下来会访问到哪个页面。操作系统无法提前预判页面的访问序列,因此最佳置换算法是无法实现的。

先入先出置换算法FIFO
总是选择在主存中停留时间最长(即最老的)一页置换,即先进入内存的页,先退出内存。
当为进程分配的物理块数增大时,缺页次数不减反增的异常现象称之为贝莱蒂(belay)异常。
只有FIFO算法会产生belay异常。另外FIFO算法虽然实现简单,但是该算法与进程实际运行时的规律不适应。因此先进入的页面也有可能时最经常被访问的。因此算法性能差。

最长时间未用置换算法LRU
算法思想:每次淘汰的页面时最近最久未使用的页面
实现方法:赋予每个页面对应的页表项中,用访问字段记录该页面自上次访问以来所经历的时间t。当需要淘汰一个页面时,选择现有页面中t最大的页面,即最近最久未使用。
LRU置换算法虽然时比较好的算法,但要求系统有较多的支持硬件。为了了解一个进程在内存中各个页面各有多少时间未被进程访问,以及如何快速的知道那一页时最近最久未使用的页面,须有两类硬件之一的支持:寄存器和栈。
寄存器
为了记录某进程在内存中各页的使用情况,须为每个在内存中的页面配置一个移位寄存器,可表示为
R= Rn-1 Rn-2 …R0
当进程访问某物理块时,要将相应的寄存器的Rn-1位置置成1,此时定时信号将每隔一定时间(例如100ms)将寄存器右移1位。如果我们把n位寄存器的数看作是一个整数,那么具有最小数值的寄存器所对应的页面,就是最近最久未使用的页面。


可利用一个特殊的栈来保存当前使用的各个页面的页面号。每当进程访问某页面的时候,便将该页面从栈中移除,将它压入栈顶。因此,栈顶始终是最新被访问的编号,栈底则是最近最久未使用页面的页面号。


评论
  目录