ZhangJie Software Development Engineer

操作系统与计算机体系结构学习笔记


操作系统与计算机体系结构学习笔记。

处理器

CPU是计算机的“大脑”,从内存中取出指令并执行。

为什么x86处理器不能执行ARM程序,ARM处理器也不能执行x86程序?

  • 因为每个CPU都有一套可执行的专门指令集。

寄存器以及寄存器分类?

  • (a)保存关键变量和临时数据的通用寄存器:用于解决“访问内存得到指令或数据的时间要比执行指令花费的时间长得多”的问题。
  • (b)程序计数器:保存将要取出的下一条指令的内存地址。
  • (c)程序状态字(Program Status Word, PSW)寄存器:包含了条件码位(由比较指令设置)、CPU优先级、模式(用户态或内核态,有一个二进制位控制这两种模式,当在内核态运行时,CPU可以执行指令集中的每一条指令),以及各种其他控制位。

用户程序使用系统调用(system call)以陷入内核并调用操作系统。TRAP指令把用户态切换成内核态。当有关工作完成后,在系统调用后面的指令把控制权返回给用户程序。

存储器

存储器系统采用一种分层次的结构:寄存器、高速缓存、主存、磁盘。

  • 寄存器 存储器系统的顶层是CPU中的寄存器,用与CPU相同的材料制成,和CPU一样快。

  • 高速缓存 高速缓存,多数由硬件控制。

  • 主存 主存被分割成高速缓存行(cache line), 其典型大小为64字节,地址0~63对应高速缓存行0, 地址64~127对应高速缓存行1,以此类推。

当某个程序需要读一个存储字时,高速缓存硬件检查所需要的高速缓存行是否在高速缓存中。

现代CPU中设计了两个缓存:L1缓存(一级缓存),L2缓存(二级缓存)。 L1缓存通常用来将已解码的指令调入CPU的执行引擎。 L2缓存用来存放近来使用过的若干兆字节的内存字。

主存,通常称为随机访问存储器(Random Access Memory, RAM)。

  • 闪存 闪存(flash memory),非易失性,可以擦除和重写。在便携式电子设备中,闪存通常作为存储媒介,例如:数码相机中的胶卷,便携式音乐播放器的磁盘。闪存在速度上介于RAM和磁盘之间。

  • 磁盘(硬盘) 磁盘(硬盘),一种机械装置,低速。 固态硬盘不是磁盘的磁盘,没有可以移动的部分,外形也不像唱片,数据是存储在存储器(闪存)中的。

进程与线程

进程与线程的区别

  1. 进程一般被定义成一个正在运行的程序的一个实例,由两部分构成:
    • (1)一个内核对象,操作系统用它来管理进程。内核对象也是系统保存进程统计信息的地方。
    • (2)一个地址空间,其中包含所有可执行文件(exe)或DLL模块的代码和数据。还包含动态内存分配,比如线程堆栈和堆的分配。
  2. 进程是有“惰性”的,进程从来不执行任何东西,它只是一个线程的容器,进程要做任何事情,都必须让一个线程在它的上下文中运行。该线程负责执行进程地址空间包含的代码。事实上,一个进程可以有多个线程,所有线程都在进程的地址空间中“同时”执行代码。为此,每个线程都有它自己的一组CPU寄存器和它自己的堆栈。每个进程至少要有一个线程来执行进程地址空间包含的代码。当系统创建一个进程的时候,会自动为进程创建第一个线程(主线程:primary thread),然后这个线程再创建更多的线程,后者再创建更多的线程…。

  3. 线程也有两个组成部分:
    • (1)一个是线程的内核对象,操作系统用它管理线程。系统还用内核对象来存放线程统计信息。
    • (2)一个线程栈,用于维护线程执行时所需的所有函数参数和局部变量。
  4. 线程必然是在某个进程的上下文中创建的,而且会在这个进程内部“终其一生”。这意味着线程要在其进程的地址空间内执行代码和处理数据。所以,假如一个进程上下文中有两个以上的线程运行,这些线程将共享同一个地址空间,可以执行同样的代码,处理同样的数据。

  5. 相较于线程,进程所使用的系统资源更多,原因在于地址空间,为一个进程创建一个虚拟的地址空间需要大量的系统资源,系统中会发生大量的记录活动,而这需要用到大量的内存;而且,由于exe和dll文件需要加载到一个地址空间,所以还需要用到文件资源。另一方面,线程使用的系统资源要少得多,只有一个内核对象和一个栈,几乎不涉及记录活动,所以不需要占用多少内存。

  6. 从进程派生(fork)一个子进程存在的问题:
    • (1)fork是昂贵的。内存映像要从父进程拷贝到子进程,所有描述字要在子进程中复制等等。目前的实现使用一种称作写时拷贝(copy-on-write)技术;
    • (2)fork子进程后,需要用进程间通信(IPC)在父子进程之间传递信息。fork之前的信息容易传递,因为子进程从一开始就有父进程数据空间及所有描述字的拷贝,但是从子进程返回信息给父进程需要做更多的工作。
  7. 创建线程要比创建进程快10~100倍。

  8. 一个进程中的所有线程不仅共享全局变量,而且共享:
    • 进程指令;
    • 大多数数据;
    • 打开的文件(如描述字);
    • 信号处理程序和信号处置;
    • 当前工作目录;
    • 用户ID和组ID。
  9. 每个线程有自己的:
    • 线程ID;
    • 寄存器集合,包括程序计数器和栈指针;
    • 栈(用于存放局部变量和返回地址);
    • errno;
    • 信号掩码;
    • 优先级。

fork和exec函数

函数fork是Unix中派生新进程的唯一方法。

#include <unistd.h>
pid_t fork(void);

fork调用一次返回两次。 在调用进程(父进程),返回一次,返回值是新派生进程(子进程)的进程ID;在子进程它还返回一次,返回值为0。

因此,可以通过返回值来判断当前进程是子进程还是父进程。

fork在子进程返回0而不是父进程的ID,原因:子进程只有一个父进程,它总可以调用getppid得到父进程的ID;而父进程有许多子进程,它没有办法来得到各个子进程的ID,如果父进程想跟踪所有子进程的ID,它必须记住fork的返回值。

线程的三种状态

  • 运行(Running): 此时线程正在执行。
  • 就绪(Ready): 此时线程可以立刻运行, 但CPU已经被占用。
  • 等待(Waiting): 此时线程正在等待某一事件(通常是I/O或同步)发生, 无法执行。

可能的转换:

  • 就绪 -> 运行:需要选择一个新进程运行时,操作系统的调度器或分配器根据某种调度算法选择一个处于就绪态的进程。

  • 运行 -> 就绪:最常见的原因是,正在运行的进程到达了“允许不中断执行”的最大时间段,该把处理器的资源释放给其他在就绪态的进程使用了;还有一种原因可能是由于具有更改优先级的就绪态进程抢占了该进程的资源,使其被中断转换到就绪态。

  • 运行 -> 等待:如果进程请求它必须等待的某些事件,例如一个无法立即得到的资源(如I/O操作),只有在获得等待的资源后才能继续进程的执行,则进入等待态(阻塞态)。

  • 等待 -> 就绪:当等待的事件发生时,处于阻塞态的进程转换到就绪态。

多线程同步和互斥

  • 线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。
  • 线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。

  • 二元信号量(Binary Semaphore)

二元信号量是最简单的一种锁,只有2种状态:占用与非占用。它适合只能被唯一一个线程独占访问的资源。当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。

  • 信号量(Semaphore)

多元信号量简称信号量,一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操作:(1)将信号量的值减1;(2)如果信号量的值小于0,则进入等待状态,否则继续执行。

访问完资源后,线程释放信号量,进行如下操作:(1)将信号量的值加1;(2)如果信号量的值大于1,唤醒一个等待中的线程。

  • 互斥量(Mutex)

互斥量和二元信号量很类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取和释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。

  • 临界区(Critical Section)

临界区是比互斥量更加严格的同步手段。在术语中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别:互斥量和信号量在系统的任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁是合法的;然而,临界区的作用范围仅限于本进程,其他的进程无法获取该锁。除此之外,临界区具有和互斥量相同的性质。

  • 读写锁(Read-Write Lock)

读写锁致力于一种更加特定的场合的同步。对于一段数据,多个线程同时读取总是没有问题的,但假设操作都不是原子的,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果使用上述信号量、互斥量或临界区中的任何一种来进行同步,尽管可以保证程序正确,但对于读取频繁,而仅仅偶偶写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个锁,读写锁有两种获取方式,共享的和独占的。

  • 条件变量(Condition Variable)

条件变量作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续执行。也就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。

  • 自旋锁(spinlock)

自旋锁不会引起调用者睡眠,而是一直循环在那里看是否该自旋锁的持有者已经释放了锁,”自旋”一词就是因此而得名。锁一旦被释放,就会被等待的线程立即获取,而不需要经过唤醒和上下文切换。

消耗过多CPU资源:如果申请不成功,申请者将一直循环判断,这无疑降低了CPU的使用率。

自旋锁是一种比较低级的资源保护方式,比较适用于保护访问耗时较短的资源。

自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。

实现生产者-消费者模型

#include <vector>
#include <thread>
#include <mutex>
#include <deque>
#include <condition_variable>

template <typename T>
class BlockingQueue 
{
public:
    BlockingQueue() {}
    ~BlockingQueue() {}

    T get() {
        std::unique_lock<std::mutex> lock(mutex_);
        while (queue_.empty()) {
            cv_.wait(lock);
        }
        
        T x = queue_.front();
        queue_.pop_front();
        return x;
    }

    void put(const T& x) {
        std::unique_lock<std::mutex> lock(mutex_);
        queue_.push_back(x);
        cv_.notify_one();
    }

    size_t size() {
        std::unique_lock<std::mutex> lock(mutex_);
        return queue_.size();
    }

private:
    std::deque<T> queue_;
    std::mutex mutex_;
    std::condition_variable cv_;
};


BlockingQueue<int> blockingQueue;

void produce(const std::string& name)
{
    int n = 0;
    for (;;) {
        blockingQueue.put(n);
        printf("%s 生产:%d, 队列大小:%d\n", name.c_str(), n, blockingQueue.size());
        ++n;
        std::this_thread::sleep_for(std::chrono::milliseconds(2000));
    }
}

void consume(const std::string& name)
{
    for (;;) {
        int n = blockingQueue.get();
        printf("%s 消费:%d, 队列大小:%d\n", name.c_str(), n, blockingQueue.size());
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

int main()
{
    std::thread p1(produce, "生产者1");
    std::thread p2(produce, "生产者2");
    std::thread c(consume, "消费者");

    p1.join();
    p2.join();
    c.join();

    return 0;
}

UNIX进程间通信方式(IPC)

  1. 管道(Pipe):管道可用于具有亲缘关系进程间的通信,允许一个进程和另一个与它有共同祖先的进程之间进行通信。

  2. 命名管道(named pipe):命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。命名管道在文件系统中有对应的文件名。命名管道通过命令mkfifo或系统调用mkfifo来创建。

  3. 信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)。

  4. 消息(Message)队列:消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺陷。

  5. 共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。

  6. 内存映射(mapped memory):内存映射允许任何多个进程间通信,每一个使用该机制的进程通过把一个共享的文件映射到自己的进程地址空间来实现它。

  7. 信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。

  8. 套接口(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:Linux和System V的变种都支持套接字。

管道
#include <unistd.h>
int pipe(int fd[2]);

该函数返回两个文件描述字:fd[0]和fd[1]。前者打开来读,后者打开来写。

Posix信号处理

信号是发生某事件时对进程的通知,有时称为软中断。它一般是异步的,也就是说,进程不可能提前知道信号发生的时间。信号可以由一进程发往另一进程(或本身);可以由内核发往某进程。

  • SIGCHLD: 内核在某进程终止时发给此终止进程的父进程的信号。

每个信号都有一个处理办法(disposition),也称为与信号关联的行为(action)。通过调研函数sigaction来设置一个信号的处理办法,并有三个选择:

  1. 提供一个函数,在信号发生时随即调用。这个函数称为信号处理程序(signal handler),而此行为则称为捕获(catching)信号。有两个信号不能被捕获,分别为SIGKILL和SIGSTOP。
void handler(int signo);
  1. 设置信号的处理办法为SIG_IGN来忽略它,但SIGKILL和SIGSTOP不能忽略。

  2. 设置信号的处理办法为SIG_DFL来为它设置默认处理方法,一般来说,默认方法是在接收到信号时终止进程。

  • 处理SIGCHLD信号

处理Zombie进程,可以建立一个信号处理程序来捕获信号SIGCHLD,在处理程序中调用wait。在调用listen之后,增加函数调用:

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
signal(SIGCHLD, sig_child);

void sig_child() {
    pid_t pid;
    int stat;
    pid = wait(&stat);
    printf("child %d terminated.\n", pid);
    return;
}
  • wait和waitpid函数 ```c #include <sys/types.h> #include <sys/wait.h>

pid_t wait(int *status);

pid_t waitpid(pid_t pid, int *status, int options); ``` 调用函数wait来处理终止的子进程。

函数wait和waitpid均返回两个值:函数的返回值是终止子进程的进程ID号,子进程的终止状态(一个整数)则是通过指针返回。

copy-on-write机制及应用

简单来说,在复制一个对象时并不是真的在内存中把原来对象的数据复制一份到另外一个地址,而是在新对象的内存映射表中指向同原对象相同的位置,并且把那块内存的 Copy-On-Write 位设为 1。在对这个对象执行读操作的时候,内存数据没有变动,直接执行就可以。在写的时候,才真正将原始对象复制一份到新的地址,修改新对象的内存映射表到这个新的位置,然后往这里写。 这个技术需要跟虚拟内存和分页同时使用,其好处是在复制对象的时候因为并不是真的复制,而只是建了一个“指针”,因而大大提高性能。但这并不是一直成立的,前提是在复制新对象之后,进行的写操作只是在一小部分的内存分页上,大部分分页不会用到或者只是读取。不然会产生大量的分页错误,得不偿失。 作用是提高内存的使用率和共享率,在线程同步,迟缓写入等方面都有应用。

文件描述符(File Descriptor) 与 句柄(Handle)

在操作系统层面上,文件操作也有类似于FILE的一个概念,在Linux里,这叫做文件描述符(File Descriptor),而在Windows里,叫做句柄(Handle)。

用户通过某个函数打开文件以获得句柄,此后用户操纵文件皆通过该句柄进行。

设计这么一个句柄的原因:句柄可以防止用户随意读写操作系统内核的文件对象,无论是Linux还是Windows,文件句柄总是和内核的文件对象相关联的,但如何关联细节用户并不可见。内核可以通过句柄来计算出内核里文件对象的地址,但此能力并不对用户开放。

在Linxu中,值为0、1、2的fd分别代表stdin、stdout、stderr。在程序中打开文件得到的fd从3开始增长。fd具体是什么呢?在内核中,每个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每个元素都指向一个内核的打开文件对象。而fd,就是这个表的下标。当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为fd。由于这个表处于内核,并且用户无法访问到,因此用户即使拥有fd,也无法得到打开文件对象的地址,只能够通过系统提供的函数来操作。

对于Windows中的句柄,与Linux中的fd大同小异,不过Windows的句柄并不是打开文件表的下标,而是其下标经过某种线性变换之后的结果。

物理内存和虚拟内存

由于操作系统的进程与进程之间是共享 CPU 和内存资源的,因此需要一套完善的内存管理机制防止进程之间内存泄漏的问题。为了更加有效地管理内存并减少出错,现代操作系统提供了一种对主存的抽象概念,即是虚拟内存(Virtual Memory)。虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。

物理内存

物理内存(Physical memory)是相对于虚拟内存(Virtual Memory)而言的。物理内存指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。在应用中,自然是顾名思义,物理上,真实存在的插在主板内存槽上的内存条的容量的大小。

虚拟内存

虚拟内存是计算机系统内存管理的一种技术。 它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)。而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来。 目前,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux 系统的交换空间等等。

虚拟内存地址和用户进程紧密相关,一般来说不同进程里的同一个虚拟地址指向的物理地址是不一样的,所以离开进程谈虚拟内存没有任何意义。每个进程所能使用的虚拟地址大小和 CPU 位数有关。在 32 位的系统上,虚拟地址空间大小是 2 ^ 32 = 4G,在 64位系统上,虚拟地址空间大小是 2 ^ 64= 2 ^ 34G,而实际的物理内存可能远远小于虚拟内存的大小。每个用户进程维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射的。

当进程执行一个程序时,需要先从先内存中读取该进程的指令,然后执行,获取指令时用到的就是虚拟地址。这个虚拟地址是程序链接时确定的(内核加载并初始化进程时会调整动态库的地址范围)。为了获取到实际的数据,CPU 需要将虚拟地址转换成物理地址,CPU 转换地址时需要用到进程的页表(Page Table),而页表(Page Table)里面的数据由操作系统维护。

其中页表(Page Table)可以简单的理解为单个内存映射(Memory Mapping)的链表(当然实际结构很复杂),里面的每个内存映射(Memory Mapping)都将一块虚拟地址映射到一个特定的地址空间(物理内存或者磁盘存储空间)。每个进程拥有自己的页表(Page Table),和其它进程的页表(Page Table)没有关系。

通过上面的介绍,我们可以简单的将用户进程申请并访问物理内存(或磁盘存储空间)的过程总结如下:

  1. 用户进程向操作系统发出内存申请请求;
  2. 系统会检查进程的虚拟地址空间是否被用完,如果有剩余,给进程分配虚拟地址;
  3. 系统为这块虚拟地址创建的内存映射(Memory Mapping),并将它放进该进程的页表(Page Table);
  4. 系统返回虚拟地址给用户进程,用户进程开始访问该虚拟地址;
  5. CPU 根据虚拟地址在此进程的页表(Page Table)中找到了相应的内存映射(Memory Mapping),但是这个内存映射(Memory Mapping)没有和物理内存关联,于是产生缺页中断;
  6. 操作系统收到缺页中断后,分配真正的物理内存并将它关联到页表相应的内存映射(Memory Mapping),中断处理完成后 CPU 就可以访问内存了;
  7. 当然缺页中断不是每次都会发生,只有系统觉得有必要延迟分配内存的时候才用的着,也即很多时候在上面的第 3 步系统会分配真正的物理内存并和内存映射(Memory Mapping)进行关联。

在用户进程和物理内存(磁盘存储器)之间引入虚拟内存主要有以下的优点:

  • 地址空间:提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单;
  • 进程隔离:不同进程的虚拟地址之间没有关系,所以一个进程的操作不会对其它进程造成影响;
  • 数据保护:每块虚拟内存都有相应的读写属性,这样就能保护程序的代码段不被修改,数据块不能被执行等,增加了系统的安全性;
  • 内存映射:有了虚拟内存之后,可以直接映射磁盘上的文件(可执行文件或动态库)到虚拟地址空间。这样可以做到物理内存延时分配,只有在需要读相应的文件的时候,才将它真正的从磁盘上加载到内存中来,而在内存吃紧的时候又可以将这部分内存清空掉,提高物理内存利用效率,并且所有这些对应用程序是都透明的;
  • 共享内存:比如动态库只需要在内存中存储一份,然后将它映射到不同进程的虚拟地址空间中,让进程觉得自己独占了这个文件。进程间的内存共享也可以通过映射同一块物理内存到进程的不同虚拟地址空间来实现共享;
  • 物理内存管理:物理地址空间全部由操作系统管理,进程无法直接分配和回收,从而系统可以更好的利用内存,平衡进程间对内存的需求。

并发与并行

并发与并行的联系和区别

与并发相近的另一个概念是并行(Parallel)。和并发所描述的情况一样,并行也是指两个或多个任务被同时执行。但是严格来讲,并发和并行的概念并是不等同的,两者存在很大的差别。

Erlang 之父 Joe Armstrong 的观点:

  • 并发是两个等待队列中的人同时去竞争一台咖啡机, 两队列中的排队者也可能约定交替使用咖啡机,也可能是大家同时竞争咖啡机,谁先竞争到咖啡机谁使用,不过后一种的方法可能引发冲突,因为两个队列里面排在队列首位的人可能同时使用咖啡机,每个等待者在使用咖啡机之前不仅需要知道排在他前面那个人是否已经使用完了咖啡机,还需知道另一个队列中排在首位的人是否也正准备使用咖啡机。

  • 而并行是每个队列拥有自己的咖啡机,两个队列之间并没有竞争的关系,队列中的某个排队者只需等待队列前面的人使用完咖啡机,然后再轮到自己使用咖啡机。

因此,并发意味着多个执行实体(比方说上面例子中的人)可能需要竞争资源(咖啡机),因此就不可避免带来竞争和同步的问题;而并行则是不同的执行实体拥有各自的资源,相互之间可能互不干扰。

Go 发明者之一 Rob Pike 的观点:

  • Concurrency is about dealing with lots of things at once.

  • Parallelism is about doing lots of things at once.

Concurrency provides a way to structure a solution to solve a problem that may (but not necessarily) be parallelizable.

其他观点:

  • 并发(Concurrence):指两个或两个以上的事件或活动在同一时间间隔内发生。并发的实质是单个物理 CPU(也可以多个物理CPU) 在若干道程序之间多路复用,并发可以对有限物理资源强制行使多用户共享以提高效率.

  • 并行(Parallelism)指两个或两个以上事件或活动在同一时刻发生。在多道程序环境下,并行性使多个程序同一时刻可在不同CPU上同时执行.

并发是一个处理器同时处理多个任务,而并行多个处理器或者是多核的处理器同时处理多个不同的任务。前者是逻辑上的同时发生(simultaneous),而后者是物理上的同时发生。

并发编程

  • 多进程。多进程是在操作系统层面进行并发的基本模式。同时也是开销最大的模式。在Linux平台上,很多工具链正是采用这种模式在工作。比如某个Web服务器,它会有专门的进程负责网络端口的监听和链接管理,还会有专门的进程负责事务和运算。这种方法的好处在于简单、进程间互不影响,坏处在于系统开销大,因为所有的进程都是由内核管理的。

  • 多线程。多线程在大部分操作系统上都属于系统层面的并发模式,也是我们使用最多的最有效的一种模式。目前,我们所见的几乎所有工具链都会使用这种模式。它比多进程的开销小很多,但是其开销依旧比较大,且在高并发模式下,效率会有影响。

  • 基于回调的非阻塞/异步IO。这种架构的诞生实际上来源于多线程模式的危机。在很多高并发服务器开发实践中,使用多线程模式会很快耗尽服务器的内存和CPU资源。而这种模式通过事件驱动的方式使用异步IO,使服务器持续运转,且尽可能地少用线程,降低开销,它目前在Node.js中得到了很好的实践。但是使用这种模式,编程比多线程要复杂,因为它把流程做了分割,对于问题本身的反应不够自然。

  • 协程。协程(Coroutine)本质上是一种用户态线程,不需要操作系统来进行抢占式调度,且在真正的实现中寄存于线程中,因此,系统开销极小,可以有效提高线程的任务并发性,而避免多线程的缺点。使用协程的优点是编程简单,结构清晰;缺点是需要语言的支持,如果不支持,则需要用户在程序中自行实现调度器。目前,原生支持协程的语言还很少。

动态链接及静态链接

动态链接与静态链接相比,性能损失大约在5%以下。但是采用动态链接的方式可以节省空间、在程序构建和升级时更加灵活。

静态链接库的优点

  • (1) 代码装载速度快,执行速度略比动态链接库快;
  • (2) 只需保证在开发者的计算机中有正确的.LIB文件,在以二进制形式发布程序时不需考虑在用户的计算机上.LIB文件是否存在及版本问题。

动态链接库的优点

  • (1) 更加节省内存并减少页面交换;
  • (2) DLL文件与EXE文件独立,只要输出接口不变(即名称、参数、返回值类型和调用约定不变),更换DLL文件不会对EXE文件造成任何影响,因而极大地提高了可维护性和可扩展性;
  • (3) 不同编程语言编写的程序只要按照函数调用约定就可以调用同一个DLL函数;
  • (4) 适用于大规模的软件开发,使开发过程独立、耦合度小,便于不同开发者和开发组织之间进行开发和测试。

不足之处

  • (1) 使用静态链接生成的可执行文件体积较大,包含相同的公共代码,造成浪费;
  • (2) 使用动态链接库的应用程序不是自完备的,它依赖的DLL模块也要存在,如果使用载入时动态链接,程序启动时发现DLL不存在,系统将终止程序并给出错误信息。而使用运行时动态链接,系统不会终止,但由于DLL中的导出函数不可用,程序会加载失败;速度比静态链接慢。

缓冲区溢出

当一个超长的数据进入到缓冲区时,超出部分就会被写入其他缓冲区,其他缓冲区存放的可能是数据、下一条指令的指针,或者是其他程序的输出内容,这些内容都被覆盖或者破坏掉。可见一小部分数据或者一套指令的溢出就可能导致一个程序或者操作系统崩溃。

常见的调度算法

调度算法是指:根据系统的资源分配策略所规定的资源分配算法。

为什么需要有这么多不同的调度算法?调度算法主要看重什么?

  1. 最小化响应时间:即用户执行操作的时间,也是用户感受到的时间。
  2. 最大化吞吐量:即OS每秒执行的作业量。
  3. 公平:就是要让CPU、内存等资源能够公平分配给不同的tasks,如果一个task过久地占据系统资源不放,而其他的task处于等待状态,那这个调度算法就不是一个公平的调度算法。例如先来先服务算法,假如先来的一个任务,处理时间(CPU burst)很长,后面来的任务都处于等待状态。那显而易见,先来先服务算法就不是一个很公平的算法。

没有一个调度算法可以兼顾这三点,想要最小化响应时间,就意味着一定会有较大的开销,这就和最大化吞吐量相悖;想要公平,就意味着无法兼顾最小化响应时间,也就是无法对I/O操作最快速度反馈;而最大化吞吐量的算法,通常也不是最小化响应时间和最公平的算法。

一、FCFS:先来先服务和短作业(进程)优先调度算法

  1. 先来先服务调度算法

先来先服务(FCFS)调度算法是一种最简单的调度算法,该算法既可用于作业调度, 也可用于进程调度。FCFS算法比较有利于长作业(进程),而不利于短作业(进程)。由此可知,本算法适合于CPU繁忙型作业, 而不利于I/O繁忙型的作业(进程)。

  1. 短作业(进程)优先调度算法

短作业(进程)优先调度算法(SJ/PF)是指对短作业或短进程优先调度的算法,该算法既可用于作业调度, 也可用于进程调度。但其对长作业不利;不能保证紧迫性作业(进程)被及时处理;作业的长短只是被估算出来的。

二、FPF高优先权优先调度算法

  1. 优先权调度算法的类型

为了照顾紧迫性作业,使之进入系统后便获得优先处理,引入了最高优先权优先(FPF)调度算法。 此算法常被用在批处理系统中,作为作业调度算法,也作为多种操作系统中的进程调度,还可以用于实时系统中。当其用于作业调度, 将后备队列中若干个优先权最高的作业装入内存。当其用于进程调度时,把处理机分配给就绪队列中优先权最高的进程,此时, 又可以进一步把该算法分成以下两种:

1)非抢占式优先权算法

2)抢占式优先权调度算法(高性能计算机操作系统)

  1. 优先权类型

对于最高优先权优先调度算法,其核心在于:它是使用静态优先权还是动态优先权, 以及如何确定进程的优先权。

3.动态优先权

高响应比优先调度算法为了弥补短作业优先算法的不足,我们引入动态优先权,使作业的优先等级随着等待时间的增加而以速率a提高。 该优先权变化规律可描述为:优先权=(等待时间+要求服务时间)/要求服务时间;即 =(响应时间)/要求服务时间。

三、基于时间片的轮转调度算法

1.时间片轮转法

时间片轮转法一般用于进程调度,每次调度,把CPU分配队首进程,并令其执行一个时间片。当执行的时间片用完时,由一个记时器发出一个时钟中断请求,该进程被停止,并被送往就绪队列末尾;依次循环。

  1. 多级反馈队列调度算法

多级反馈队列调度算法多级反馈队列调度算法,不必事先知道各种进程所需要执行的时间,它是目前被公认的一种较好的进程调度算法。 其实施过程如下:

1) 设置多个就绪队列,并为各个队列赋予不同的优先级。在优先权越高的队列中,为每个进程所规定的执行时间片就越小。

2) 当一个新进程进入内存后,首先放入第一队列的末尾,按FCFS原则排队等候调度。 如果他能在一个时间片中完成,便可撤离;如果未完成,就转入第二队列的末尾,在同样等待调度…… 如此下去,当一个长作业(进程)从第一队列依次将到第n队列(最后队列)后,便按第n队列时间片轮转运行。

3) 仅当第一队列空闲时,调度程序才调度第二队列中的进程运行;

仅当第1到第( i-1 )队列空时, 才会调度第i队列中的进程运行,并执行相应的时间片轮转。

4) 如果处理机正在处理第i队列中某进程,又有新进程进入优先权较高的队列, 则此新队列抢占正在运行的处理机,并把正在运行的进程放在第i队列的队尾。


Comments

Content