September 28, 2015
·
读书笔记
Linux学习笔记
- 操作系统内核:管理和分配计算机资源(CPU,内存和设备)的核心层软件
- 进程调度:Linux属于抢占式多任务操作系统,内核的进程调度程序负责管理进程对cpu的使用权。
- 内存管理:进程之间彼此隔离,虚拟内存管理
- 文件系统
- 进程管理:创建、终止进程,为其分配/释放资源
- 外接设备访问:提供标准化接口,仲裁并发访问
- 网络
- Linux系统Api
- cpu有两种运行状态,内核态和用户态;与之对应的有内核空间内存和用户空间内存。
- cpu在用户态运行时,只能访问用户空间内存。
- cpu在内核态运行时,既可以访问内核空间内存,也可以访问用户空间内存。
- 进程不能直接创建新进程、创建管道,也不知道它访问的内存/磁盘文件的具体物理位置,都需要通过调用内核实现。
- 内核主要是用C语言写的
- 文本:代码区
- 数据:静态变量
- 堆:动态分配的内存
- 栈:局部变量、函数调用
- 系统引导的时候会开启很多服务,这些服务就叫做守护进程
- Linux是分时操作系统,但可以通过内核的进程调度和中断机制实现软实时
- 系统调用:cpu从用户态到内核态。时间开销远大于函数调用。
- 所有系统调用都是以原子操作的方式执行的,不会被其他线程/进程中断
- fd=open(pathname,flags,mode) //不存在则创建
- numread=read(fd,buffer,count)
- numwritten=write(fd,buffer,count)
- status=close(fd)
- lseek(fd,offset,whence) //调整文件指针
- pread/pwrite (fd,buffer,count,offset) //指定偏移量读/写,避免了多线程对文件偏移量的竞争
- readv/writev //多缓冲区分散读/写
- 该接口可以对所有类型的文件进行操作,包括管道(内存中,但是仍然是文件)、socket、终端、设备、普通文件
- 文件描述符:用户空间只可以访问文件描述符,为每个进程独自维护(为进程中所有的线程共用)。dup函数可以复制文件描述符(如图进程A的fd1和20)。
- 打开文件表:系统唯一,表中的每一行由open语句创建(open同一个文件两次会产生两行,但是同时指向一个i-node项)。文件访问的偏移量在这里维护(如果两个文件描述符指向同一行打开文件表,那么这两个文件描述符共享同一个偏移量)。
- i-node:系统唯一,对应了文件系统的每一个文件,包含的文件的所有属性和指向数据块的指针。
- 偏移量并发竞争:如上图,竞争会在于打开文件句柄的文件偏移量。由于每一次open会创建新的打开文件句柄,因此通常发生在进程共享文件描述符的情况。可以使用pwrite()解决。
- 系统调用并发竞争:write()写入普通文件时,通常来说是原子的(除非发生异常),因此不会出现
- 文件操作并发:如多线程/进程进行read-modify-write的操作,可能出现丢失更新的情况。此时要使用文件锁(见下)解决。
- read()和write()不会直接操作磁盘,而是读/写内核缓冲区。
- 读:磁盘数据先拷贝到内核缓冲区,read()从内核缓冲区读。(对于序列化文件会尝试预读)
- 写:write()数据拷贝到内核缓冲区随即返回,后续某个时刻会刷盘
- 使用 int fsync(fd);可以强制刷盘,刷盘成功后才会返回
- 使用 int fdatasync(fd);也可以强制刷盘但不刷新元数据(时间戳等),因此会快一些
- 调用open()时指定O_SYNC标志,也可以使后续输出同步刷盘
- 对于stdio,在用户态内存中也存在另一层缓冲
- 进程可以通过posix_fadvise()系统调用,告诉内核可能的读取方案(随机/顺序等)以优化读写。
- 使用O_DIRECT标志open()也可以绕过所有缓冲区,但是很不建议使用这种方式。(没有优化/可能与其他进程冲突/对齐等问题)
- 由于stdio会在用户空间缓冲一层(上图),因此stdio函数和文件锁不宜混合使用,应该用系统调用read(),write()来代替fread(),fwrite()。
- int flock(int fd, int operation)
- LOCK_SH:在fd对应文件上加共享锁
- LOCK_EX:在fd对应文件上加互斥锁
- LOCK_UN:解锁fd对应文件
- LOCK_NB:使用|与上述三个共同使用。非阻塞请求,即拿不到锁时不阻塞,直接返回-1
- 简述共享锁和互斥锁,假设A进程先获取了锁,下图表示B进程能否拿到锁:
- 不论进程的文件的访问方式是什么(读/写/读写),都可以对其加文件锁。
- 共享锁可以升级为互斥锁,细节略。
- write()函数可以向 大于文件当前长度的 文件偏移量的位置写入数据。这就导致了文件中间形成了一块空洞。
- 空洞不占磁盘空间。因此一个文件名义上的大小可能大于占用的磁盘空间。
- 例如迅雷下载的文件,就是利用文件空洞先创建一个大文件,然后多线程在不同的偏移量下写数据。
- 日志文件系统,有利于系统崩溃时通过redo恢复数据和元数据的一致性,但也因此增加了更新时间
- 非阻塞I/O
- 添加了O_NONBLOCK位掩码的文件描述符(可以在open时添加,也可以用fcntl修改)就是非阻塞的。通常不会在磁盘文件中使用非阻塞I/O,这里在语义上介绍一下非阻塞I/O,主要用于FIFO和Socket
- 若open()未能立即打开文件,则会返回错误,而不是陷入阻塞
- 在写入端打开并且可读字节为0时,read()会返回失败(EAGAIN),而不是阻塞
- 在空间不足以立即写入的情况下,write()会返回失败,而不是阻塞
- 信号又被称作软中断,用来通知进程发生了异步事件。在软件层次上是对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的
- 进程可以通过int kill(pid, sig) 或int sigqueue(pid_t pid, int sig, const union sigval val)向另一个进程发信号
- 信号 sig 为int整数 每一个数值代表一种信号。对于特定的信号,如果没有signal()定义处置函数,进程会采取该数值对应的默认方式。
- 进程可以使用void (*signal(int signum, void (*handler))(int)))(int) 或 int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));来指定信号处置函数
- 进程可以使用sigprocmask()来屏蔽/取消屏蔽指定的信号
- 如何使用信号传递数据?
- 使用sigqueue发送信号,第三个参数 sigval 结构体就是额外数据,可以是一个int或者一个void*
- 使用sigaction设置处置函数void (*_sa_sigaction)(int,struct siginfo_t *, void *),这里的函数第二个参数siginfo_t结构体包含了sigval结构体,内容就是sigqueue发来的额外数据。
- 信号处置函数(信号处理器)中不要使用不可重入的系统调用。
- 可重入(reentrant)函数需要满足两方面内容:
- 可重入函数可以由多于一个任务并发使用,而不必担心数据错误。称并发安全。
- 可重入函数可以在任意时刻被中断,稍后再继续运行,不会丢失数据。称中断安全。
- 例如,malloc就是不可重入的(不满足中断安全)。因为信号可能会直接中断正在进行的系统调用,如果当进程正在使用 malloc 在它的堆上分配额外的内存时,被信号中断,然后信号处理函数中再次调用 malloc,就会破坏malloc维持的内存区域链表,产生不可预知的后果(可能是死锁)。
- 关于可重入和线程安全:
- 通常来说,线程安全为可重入定义的第一部分,即并发安全。
- 可重入函数一般都是线程安全的。
- 线程安全的不一定是可重入的,因为使用锁机制实现线程安全的函数无法避免信号中断的问题,比如malloc
- 信号范围(signum)相比标准信号扩大,标准信号为0~31,实时信号为32~63
- 实时信号是可靠的,因为在进程阻塞该信号的时间内,发给该进程的所有相同signum的实时信号会排队,而非实时信号则会合并为一个信号。
- 进程是由内核定义的抽象的实体,并为该实体分配用以执行程序的各项系统资源。
- 进程会开辟一段用户内存空间,存储代码和数据;用内核数据结构维护进程状态信息。
- 进程的内存布局(段:Segment):
- 文本段:进程运行的程序的机器码,允许多个进程共用
- 初始化数据段:显示初始化的全局变量和静态变量
- 未初始化数据段:未显示初始化的全局变量和静态变量
- 栈:函数局部变量,函数调用相关数据。(栈顶会在每次函数返回时弹出到上一层函数,因此会释放所有局部变量和申请的内存,申请堆栈内存的方法为alloca())
- 堆:供运行时动态分配内存。申请的方式为malloc()和free()。当程序中断(program break)指针下方的内存不足以malloc一整块申请的内存时,会调用brk()调整程序中断。
- 上图的内存布局位于虚拟内存中。实际上会分成若干大小相同的页,一部分在物理内存中,一部分在磁盘的交换区中,通过页表进行内存映射。
- 系统调用fork():父进程创建子进程,子进程获得的父进程的内存数据的拷贝
- 完成fork调用后,会存在两个进程同时从fork返回处继续执行
- 两个进程执行相同的文本段,栈和数据段却不同(虽然是拷贝但是是两个副本)
- 代码通常这么写:
- 完整内存拷贝通常是昂贵的,现代Linux实现为了避免浪费,对于需要拷贝的数据段/堆/栈,采用写时复制(copy-on-write)的方法。比如fork的新进程直接调用execve,则无需拷贝原数据,而是将新进程内存的页表项指向父进程相同的物理内存页。
- 因此,也可以像上图的代码写法去调用函数(即fork和wait代替了malloc和free)可以防止内存泄露,也适合树状分析(tree analysis)
- 库函数exit():(对应系统调用_exit())终止进程的执行。
- 释放内存资源,但保留进程表中的记录,变为僵尸进程,目的是为父进程可能调用的wait()保留信息。
- 程序的终止分为正常和异常两种,异常终止由信号引起,正常终止由exit或main函数返回完成(main函数执行完成就相当于exit(0))
- 无论正常还是异常终止,内核都会执行多个清理步骤,包括关闭文件描述符和文件锁等等。
- 如果子进程的父进程终止,则其父进程变为ID为1的init进程
- 如果调用exit()而不是_exit(),将会执行由atexit()和on_exit()注册的退出处理程序,并刷新stdio缓冲区。
- 系统调用wait():阻塞当前进程并检查子进程(如果有多个子进程则返回第一个结束的,要指定特定子进程要使用系统调用waitpid())的终止状态,如果子进程未exit则等待,否则返回子进程退出状态并删除僵尸进程(删除进程表中的记录)
- 系统调用execve():执行新程序,将新程序的加载到本进程的代码区。(俗称exec(),但是实际上并没有叫exec的系统调用)
- 其中第一个参数pathname可以指向一个二进制可执行文件,也可以是以“#! interpreter[arg]”开头的sh脚本
- 系统调用vfork():后期BSD(Berkeley Software Distribution,Unix的一支)引入的,类似于fork()但是专门为子进程立即执行exec()而设计的。
- 效率更高。连页表都不复制了,相当于在父进程中直接插入一个函数:在子进程调用exec或exit前,父进程会处于暂停状态。
- 应该避免这个系统调用,尽量使用fork()。因为它会直接影响父进程的内存,产生难以察觉的bug。
- 由于fork()执行之后先执行子进程还是父进程是不确定的(尽管绝大多数情况是继续执行父进程),若要保证执行顺序,必须使用同步技术:
- 如信号:想要父进程等待子进程,在fork之前阻塞同步信号(sigaction()),在子进程中kill发送信号,父进程中sigsuspend()即可。
- 特权进程可以使用acct()系统调用来开关进程记账功能,开启后内核会在每个进程终止时 记录相关信息到系统的进程记账文件中。
- 系统调用clone():类似fork,但产生的子进程不以调用处为起点,而是直接调用参数func所指函数。
- 参数中还需要传递一块内存指针,用于作为子进程的栈。因为子进程可能和父进程共享内存。
- 参数中还有位掩码flag,用来设置子父进程各种资源是否共享。
- 例如POSIX线程共享了:虚拟内存、文件描述符、文件系统信息、信号处置表等等等等。
- exec()和fork()继承资源的区别见书中P505
- 线程和进程都是“内核调度实体”,只不过他们的资源共享程度不同。
- POSIX线程规范(简称Pthreads)只是线程共享方案之一。POSIX线程的实现包括LinuxThreads和NPTL。
- 一个进程可以包含多个线程,他们共享初始化数据段、未初始化数据段、堆内存段。
- 内存共享,便于线程间通信
- 创建线程(系统调用clone())的代价远小于创建进程(系统调用fork())
- 多线程编程需要考虑线程安全
- 多线程程序中,所有线程必须运行同一个程序
- 创建线程:库函数pthread_create(pthread_t *thread,const pthread_attr_t *attr, (void*)(*start)(void*),void *arg)
- 参数1 thread为创建的线程的指针。
- 参数2 attr定义了线程的各个属性
- 新线程通过调用带有参数arg的函数start()来执行。
- start()函数return
- 本线程调用pthread_exit()
- 调用pthread_cancel(pthread_t thread)取消另一个线程
- 调用即刻返回。在默认状态下,当被取消线程到达取消点(大部分都是阻塞函数)时,就会终止线程。
- 可以使用pthread_cleanup_push(),在当线程被取消时,添加清理函数(可以有多个,是一个函数栈)。可以用来释放内存、解锁互斥量。
- 线程的连接:pthread_join() 等待参数中所指线程,直到终止时返回并回收资源。(类似wait(),waitpid(),回收僵尸线程)
- 线程的分离:pthread_detach() 给线程设置标记,使线程终止时自动清理资源,避免产生僵尸进程。
- 线程同步:
- 下面所述的互斥量和条件变量,就像Java的synchronized和wait/notify,是Monitors: An Operating System Structuring Concept论文中提到的线程同步方式的一种实现。
- 临界区:访问某一共享资源的代码片段。
- 互斥量:
- pthread_mutex_lock(pthread_mutex_t*)可以锁定互斥量,若其他线程已经锁定该互斥量则会阻塞,若本线程之前已经锁定该互斥量则会死锁或异常(在RECURSIVE属性下则会通过计数器增加锁定次数而不会死锁)。
- pthread_mutex_unlock(pthread_mutex_t*)可以解锁本线程之前锁定的互斥量。
- 死锁避免方法:
- 定义互斥量的层级关系,必须先锁定高层级,再锁定底层级(常用)
- 通过pthread_mutex_trylock(),如果已被锁定则稍后再试。
- 产生背景:在一个典型的生产者-消费者模型中,如果采用互斥量实现,消费者需要不断轮询生产队列,浪费CPU资源。条件变量则会挂起wait的线程直到被唤醒。
- pthread_cond_signal(pthread_cond_t *cv):在指定的条件变量上,发送信号释放被阻塞的一个线程,如果有多个线程等待,则会根据线程调度策略决定哪一个。
- pthread_mutex_unlock和pthread_cond_signal的执行顺序可以颠倒。
- 对应Java的notify();
- pthread_cond_broadcast(pthread_cond_t *cv):唤醒所有被pthread_cond_wait阻塞在某个条件变量上的线程,这些线程被唤醒后将再次竞争相应的互斥锁。
- pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex):
- 1. 解锁互斥量mutex
- 2. 阻塞线程,直到另一个线程对该条件变量发送pthread_cond_signal
- 3. 重新锁定mutex
- 从pthread_cond_wait返回时,注意不能对判断条件(predicate)做任何假设,需要再次做判断。因为在一些实现中有可能出现虚假唤醒的情况。(Java同理)
- 对应Java的wait();
- 理论最大线程数 = 用户可访问虚拟内存空间 / 线程栈大小(缺省2MB)
- 通常来说,在线程中最好避免使用信号。
- 如果必要,可以使所有线程都阻塞信号,创建一个专门的线程用于接收和处置信号。
- 仅会复制当前线程到子进程中,其他线程会消失。
- 仅当后面紧跟exec()时可以直接使用fork(),否则需要调用pthread_atfork()添加fork处置函数,用于释放内存、解锁互斥量。
- 线程的实现模型(线程与内核调度实体KSE的映射关系):
- 内核对进程中存在的线程一无所知
- 线程的创建、调度和同步全部由用户/用户线程库来实现
- 一个线程对应一个内核调度实体;创建、调度和同步由内核实现。
- LinuxThreads和NPTL都采用1:1模型
- 进程调度和优先级(线程概念类似,下面都由进程代替)
- 进程可以分为普通进程和实时进程(以内核优先级和调度策略区分)。在任何时候,实时进程的优先级都高于普通进程(但是实时组调度出现后,允许普通进程占用少量的CPU时间,这里不讨论)
- 普通进程
- 进程优先级越高,享受的CPU时间越多。(高一级的进程比低一级的进程多占用10%的CPU)
- 取值范围 -20(高)~19(低)
- 对应内核优先级(也成静态优先级,用户不可见)100(高)~139(低)
- 循环时间共享(CFS/SCHED_OTHER):每个进程轮流使用CPU一段时间,直到挂起或时间片到期
- 取值范围 1(低)~99(高);0代表其他调度策略;
- 对应内核优先级(也成静态优先级,用户不可见)0(高)~99(低)
- 调度策略:实时进程都只会被更高优先级的实时进程抢占。二者的不同在于,对于多个持有最高优先级的进程,FIFO会使先进入的独占CPU除非主动放弃(挂起或yield),RR会分时间片轮流执行。
- 实时先入先出(SCHED_FIFO)
- 实时循环(SCHED_RR)
- 系统调用mlock(void* addr, size_t length):根据传入的虚拟内存地址,锁住该块内存所在的页表们并驻留在物理内存中而不会交换到磁盘。
- 系统调用munlock():解锁 同上
- 基本的TCP Socket函数(对应流Socket):
- 基本的UDP Socket函数(对应数据报Socket):
- 传统的I/O模型通常是阻塞式的(除非添加了O_NONBLOCK标志)
- 为了提高性能,提供了三种备选方案:I/O多路复用(select()/poll())、信号驱动I/O、epoll()
- 这三种方案实现的是同一种目的:同时检查多个文件描述符(不仅指磁盘文件哦),查看I/O操作是否就绪。(注意这里只是提醒的作用,实际的I/O操作仍然由用户进程处理)
- 由于信号和epoll涉及到Linux专有的特点,其他系统也有自己的接口(例如BSD的kqueue),在实际编写时往往使用一个抽象软件层(例如Libevent库)来增强程序的可移植性。
- 水平触发(level-triggered/LT/也称条件触发):检查文件描述符状态时(可以在任何时候检查),只要可以进行非阻塞I/O操作,就会返回通知事件。
- 边缘触发(edge-triggered/ET):只有发生了新的I/O事件时才会收到通知,在下一个I/O事件到来前不会收到任何通知。
- 意味着收到I/O事件后,程序需要尽可能多的执行I/O,否则可能失去执行的机会。
- 通常与非阻塞I/O(O_NONBLOCK)一起使用
- I/O多路复用:允许同时检查多个文件描述符,并在其中一个或多个成为就绪态时返回
- 参数包括检测的文件描述符集合(按照读/写/异常来区分三个集合)、timeout
- 返回-1表示错误,0表示超时,正数表示已就绪的文件描述符个数(三个集合的总数)
- 参数传入的文件描述符集合会在返回时被修改,留下的都是就绪态的文件描述符
- 与select()很相似,参数包括文件描述符结构体数组,包括需要检查的事件和实际发生的事件
- 以select为例,就是将文件描述符放入一个队列,循环逐个调用驱动程序的poll函数检查这个文件描述符的状态,并在每次循环中主动让出调度(cond_resched())。
- 通常情况下二者性能相差不大。如果被检查的文件描述符集合在分布范围(指0~集合中fd最大值)内很稀疏,那么poll()性能更好。
- 检查大量文件描述符时,从用户空间向内核空间拷贝数据结构的成本会增加
- 对于返回结果,用户需要逐个检查其状态
- epoll_create():创建一个epoll实例,返回其句柄epfd(其实质也是一个文件描述符,会创建一个i-node)
- epoll_ctl():修改(增加/删除)由epfd表示的epoll实例中的兴趣列表。列表中的文件描述符以结构体的形式记录(红黑树实现),包含了文件描述符和监听状态的位掩码等。
- epoll_wait():返回epoll实例中 处于就绪态的文件描述符数组
- 该方法阻塞时,其他线程可以正常进行epoll_ctl()操作
- 输入和返回的位掩码范围:
- epoll实例中除了有兴趣列表,还维护着就绪列表(ready list,兴趣列表的子集)。
- epoll_ctl()添加需要监听的文件描述符时,会将一个回调函数(ep_poll_callback)注册到对应设备的等待队列中,回调触发时(意味着文件描述符就绪)将其加入就绪列表,如果此时用户程序正阻塞在epoll_wait中则会唤醒。
- epoll_wait()调用时,如果就绪列表不为空,则直接返回就绪列表。否则等待被唤醒即可。
- 注意:epoll_wait会将注册在epfd上的已经发生事件的fd的事件类型清空,所以如果下一次epoll_wait还要监听这个fd,就要使用epoll_ctl重新设置fd的事件类型(水平触发时)或者使用边缘触发。
- 参见 http://www.cnblogs.com/apprentice89/p/3234677.html
- 边缘触发通知:在位掩码中加入EPOLLET即可。在第二次调用epoll_wait()时,如果在两次调用中间fd没有产生新的输入则阻塞。(水平触发并且中间没有调用epoll_ctl则直接返回就绪)
- 传统的I/O模型是阻塞的,只有添加了O_NONBLOCK位掩码的文件描述符是非阻塞的
- I/O多路复用(包括epoll)是阻塞的,因为它仅起到提示作用,真正的I/O操作还是使用传统I/O模型
- 以上的I/O模型(甚至包括信号驱动I/O)都是同步I/O模型。只有POSIX AIO是异步I/O模型:内核在整个操作(包括将数据从内核拷贝到用户空间)完成后通知用户进程,而同步I/O模型都需要同步的调用recvfrom来实现这个步骤。
- 抛开标准来谈同步/异步/阻塞/非阻塞都是耍流氓,例如epoll在系统调用层面就是同步阻塞的,但是在内核实现中又可能是异步非阻塞的。
- 阻塞I/O模型:
- 通常来说,磁盘或网络I/O操作都有 内核空间缓冲区←→用户空间内存 这一步骤。对于网络服务器来说,这一层内核缓冲区往往损失的很大性能(内核态/用户态的上下文切换、拷贝消耗的CPU时间和内存带宽)。因此,零拷贝技术的目的就是让用户程序避开操作系统直接访问硬件存储。
- 直接 I/O:应用程序直接访问硬件设备
- 在文件描述符的位掩码中设置O_DIRECT,即使用了直接I/O
- 使用时需要遵守一些限制:
- 传入的数据,其内存边界和数据长度必须是块大小的整数倍
- 直接访问不意味着快,在没有恰当使用的情况下往往更慢
- 内存映射文件:mmap()
- 文件向socket直接传输数据:sendfile()
- 使用举例:nginx读取静态文件并通过网络返回,使数据不必经过用户空间