《UNIX环境高级编程》Advanced Programming in UNIX Environment 摘抄

图书馆里借了一本《UNIX环境高级编程》花了约一周时间粗读完,通篇都是极其精确的定义和讲解,不愧圣经之名。下面对一些学习过程中比较觉得有心得的地方进行记录。

文件I/O & 标准I/O库

文件描述符(file descriptor)与文件指针(FILE *)有什么区别?

// chapter 1.5,3.2,5

文件描述符(file descriptor)通常是一个小的非负整数,内核用其来标识一个特定进程正在访问的文件。

当打开一个现有文件或者创建一个新文件时(open),内核向进程返回这个文件描述符。

对于每一个新运行的程序,所有shell都会为其打开三个文件描述符:标准输入、标准输出、标准错误(stdin、stdout、stderr)。其对应的file descriptor分别是0、1、2。

// chapter 3,5

第三章中的I/O接口,如openwritereadlseek都是针对文件描述符进行的。

对于标准I/O库,其操作是针对流(stream)来进行的。用标准I/O库打开一个文件,是使一个流和一个文件相关联,即打开一个流,fopen 返回一个 FILE *,该对象使一个结构,包含标准I/O库为了管理流所需要的所有信息,包括:实际I/O文件的文件描述符,缓冲区指针、长度、字符数,出错标志等。

标准I/O库缓冲

在一个流上执行第一次I/O操作时,通常会调用malloc来获得缓冲区。

  1. 全缓冲:填满缓冲区后才实际进行I/O操作。例如对于写到磁盘上的文件通常使用全缓冲。术语flush(清洗)在标准I/O库中意味着将缓冲区的内容写到磁盘上。
  2. 行缓冲:输入和输出遇到换行符时执行实际I/O操作。由于每一行缓冲区长度固定有限,只要填满了缓冲区,即使还没有换行符,也会执行I/O(类似全缓冲)
  3. 不带缓冲。标准I/O库也有不带缓冲的流。例如,如果使用标准I/O函数fputs写15个字符到不带缓冲的流上,很可能就是直接用write系统调用立即写入。

ISO C有如下要求:

  1. 当且仅当标准输入和标准输出不涉及交互式设备时,才是全缓冲的。(若涉及终端设备,则他们是行缓冲的,否则是全缓冲的。)
  2. 标准出错绝不是全缓冲的。(出错信息不带缓冲

标准I/O库与I/O函数有什么区别?

使用标准I/O库的一个优点是无需考虑缓冲和最佳bufsize的选择。

标准I/O库与直接调用readwrite函数相比并不慢很多。

标准文件I/O函数的效率与bufsize的关系

// chapter 3.9

选取bufsize值会影响读文件操作的时间。

从书中看到,如果缓冲区太小会严重影响效率。当bufsize达到4096 bytes时CPU时间就约达到最小值,继续增加bufsize不会有太多改善。

内核表示文件的数据结构 & 文件的共享结构

两个图,讲解的很清晰

原子操作

// chapter 3.11

用一段历史问题来说明这个概念。

早期的UNIX不支持open的append选项,所以要用如下代码来append:

1
2
lseek(fd, 0, 2) // 第二个参数是offset,第三个参数为2代表的意思是将当前文件偏移量设置为 offset+文件长度
write(fd, buf, 100)

对于单个进程而言这段程序是没问题的。但假设有两个进程A和B对同一文件(长度1500 bytes)进行这种操作,假定进程A调用了lssek,A的当前文件偏移量设置到了1500 bytes。假设此时内核调度B开始执行,B也执行了lseek把B进程的文件偏移量设置到了1500 bytes。然后B继续调用write写入100字节,这时文件总长度变为1600 bytes。然后内核又切换到进程A开始运行,A调用write时,就会写覆盖B写的数据。

问题就在于“定位到文件尾端,然后写”这个操作的两个步骤分成了两个函数。结局问题的方法就是让这两个操作成为一个原子操作。也就是O_APPEND标志。或者preadpwrite函数。

原子操作(atomic operation)就是由多步组成的操作,要么全部都执行,要么一步都不执行。

文件类型 & 所谓“一切皆文件”

  1. 普通文件(regular file)
  2. 目录文件(directory file)。“目录”也是文件!!这种文件里保持其他文件(也就是“该目录下的文件”)的名字和指向这些文件的指针。
  3. 块文件(block special file),提供对块设备带缓冲的访问。例如磁盘
  4. 字符文件(character special file),提供对设备不带缓冲的访问。例如终端。系统中的所有设备要么是块文件要么是字符文件。
  5. FIFO,命名管道
  6. socket
  7. 符号链接(symbolic link)

进程

// chapter 1.6 P10

程序(program)是存放在磁盘上,处于某个目录中的一个可执行文件。使用6个exec函数之一,由内核读入存储器,执行。

程序的执行实例被称为进程(process)

PID

// chapter 8

  • 每个进程都有一个非负整形表示的唯一进程ID。
  • PID是唯一的,但可以复用(已经终止的PID会赋给新的进程)。大多数UNIX系统有延迟重用算法,让新进程的PID不同于最近终止的PID。这是为了防止将新进程误认为是同一个PID的先前已经终止的那个进程。

Fork

  • fork 创建一个新进程,是父进程(调用进程)的复制品。
  • 调用一次,返回两次。 子进程的返回值是0,父进程的返回值是子进程的PID。
  • 子进程和父进程继续执行fork之后的指令。
  • 子进程是父进程的副本。例如,子进程获得父进程数据空间、堆、栈的副本。注意:是副本,而不是共享这些存储空间部分。
  • 写时复制技术(copy-on-write),fork时并不执行一个实际的父进程数据段、堆、栈完全复制,而是由内核执行写时复制,先让这些区域由父子进程共享。如果父子进程有一个试图修改这些区域,内核为那个要被修改的区域(页)制作一个副本。
  • 一般来说fork之后先执行父进程还是先执行子进程是不确定的

文件共享:

  • fork的一个特性是父进程的所有打开的文件描述符都复制到子进程中。所以父子进程会对同一个文件使用相同的文件偏移量。
  • 如果父子进程写到同一文件描述符,但没有任何形式的同步。那么他们的输出会互相混合。所以常用的处理方式有如下两种:
    • 父进程等待子进程完成。这种情况下,父进程无需对描述符做处理。子进程终止后,子进程读写的文件描述符的偏移量是更新的。
    • 父子进程执行不同的程序段。在这种情况下,fork之后父子进程应各自关闭他们不需要的文件描述符,这样就不会干扰对方使用的文件描述符,这是网络通信中常见的。

fork的用法:

  1. 一个父进程希望复制自己,然后父子进程同时执行不同的代码段。例如:网络服务进程——父进程等待客户端的连接请求,当请求到达,调用fork建立一个子进程来处理这个请求,父进程则继续等待下一个连接的到达。
  2. 一个进程要执行一个不同的程序。例如:shell。这种情况下,是fork一个子进程,子进程执行exec

exit

进程有如下五种正常的终止方式:

  1. main函数中执行return语句。等于调用exit
  2. 调用exit函数。
  3. 调用_exit或_Exit
  4. 进程的最后一个线程在其启动例程中执行返回语句。
  5. 进程的最后一个线程调用pthread_exit

有如下三种异常终止方式:

  1. 调用abort()。产生SIGABRT信号,异常终止。
  2. 当进程接收到某些信号时。
  3. 最后一个线程对“取消”(cancellation)请求做出响应。

不管进程如何终止,都会执行内核中的同一段代码,关闭所有打开的描述符,释放所占用的存储器等。

我们都希望终止进程能够通知其父进程它是如何终止的。所以对于终止函数exit,有一个退出状态(exit status)会作为参数传递给函数。内核为每个终止的子进程保存了一定量的信息,父进程可以使用wait或waitpid函数取得这些信息,至少有PID、终止状态、该进程使用的CPU时间总量等。

在UNIX术语中,一个已经终止但未被父进程善后(获取有关信息,释放所占用资源)的进程称为僵死进程(zombie)

wait

当一个进程正常终止或异常终止时,内核都会向其父进程发SIGCHLD信号。因为子进程的终止是异步事件(asynchronous,指在父进程运行中,任何时侯都可能发生),信号就是异步通知。父进程可以选择忽略,也可以提供一个该信号出现时要调用的函数(术语叫做信号处理程序,handler)。

调用waitwaitpid的进程可能会发生什么情况:

  • 如果其子进程还在运行,则阻塞。
  • 如果一个子进程已经终止,则取得该子进程的终止状态并立即返回。
  • 如果没有任何子进程,出错返回。

waitwaitpid的区别如下:

  • 在一个子进程终止前,wait会让调用者阻塞,waitpid有一个选项可以让调用者不阻塞。例如:有时候用户希望取得一个子进程的状态但不想阻塞或等待子进程结束。
  • wait是等待调用后第一个终止子进程。waitpid有若干选项,可以控制它等待的进程。
  • waitpid可以等待一个特定的进程,而wait时返回任一终止的子进程的状态。

exec

  • 当进程调用一种exec函数时,该进程的程序完全替换为新程序,新程序从其main函数开始执行。
  • 调用exec不创建新进程,所以前后PID不会改变exec只是用一个全新的程序替换了当前进程的正文、数据段、堆栈。

信号

信号是软件中断,提供一种处理异步事件的方法

信号都有名字,以SIG开头。

很多条件可以触发信号:

  • 用户按某些终端按键时
  • 硬件异常产生信号,例如除数为0
  • 进程调用kill(2)函数可以将信号发送给另一个进程或进程组。
  • 用户可以用kill(1)命令发送信号给其他进程。常用此命令来禁止一个失效的后台进程。

信号是异步事件的经典实例,产生信号的事件是随机出现的,进程不能简单地测试一个变量来判断是否出现了一个信号,而是必须告诉内核“若该信号出现,则执行下列操作”。某个信号出现的时候按照下列方式之一处理:

  1. 忽略此信号。有两种信号是不能被忽略的,是SIGKILL和SIGSTOP,这两个信号向超级用户提供了终止进程的可靠方法。
  2. 捕捉信号。为了做到这一点,要实现通知内核在某种信号出现时,调用一个用户函数。注意,不能捕捉SIGKILL和SIGSTOP
  3. 执行系统的默认动作。大多数的信号系统默认动作时终止进程。

一些典型的信号

信号 解释
SIGINT 当用户按下中断键(一般是Ctrl+C),终端驱动程序产生此信号被送至前台进程组的每一个进程。常用此信号来终止一个运行不正常,或者正在屏幕上产生大量不必要的输出的进程。
SIGKILL 两个不能被捕捉或忽略的信号之一。

函数

signal

用于指定忽略某信号,或对某信号执行系统默认动作,或注册收到某个信号后调用什么函数。这个函数称为信号处理程序(signal handler)。

kill / raise

kill 函数发送信号给进程或进程组,raise 函数允许进程向自身发送信号。

alarm / pause

使用 alarm函数可以设置一个计时器,在未来某个指定时间该计算器会超时,产生SIGALRM信号,如果不忽略或不设置handler,默认动作时终止调用 alarm 函数的进程。

pause 函数时调用进程挂起,直到捕捉到一个信号。

abort

让程序异常终止。

sleep

让调用进程挂起,直到满足下列条件之一:

  1. 经过了指定的秒数
  2. 捕捉到一个信号并从信号处理程序中返回。

线程

进程可以看作只有一个控制线程:一个进程在同一时刻只做一件事情。有了多个控制线程后,在程序设计时,可以把进程设计成在同一个时刻做不止一件事。这种方式有很多好处:

  1. 简化异步事件的代码,每个线程对于独自要处理的事件可以采用同步编程模式,比异步编程模式简单得多。
  2. 不同的进程必须使用操作系统提供的复杂机制才可以实现内存和文件描述符的共享。而多个线程可以自动共享相同的地址空间和文件描述符。
  3. 可以改善整个程序的吞吐量。有了多个控制线程,独立的任务处理可以交叉运行。
  4. 即使运行在单处理器上,也可以获得多线程编程模式的好处,处理器的数量并不影响程序结构

线程标识

像进程有PID一样,进程也有一个线程I。PID在整个系统中是唯一的,线程ID不是,线程ID只在其所在的进程环境内有效

线程创建

传统的进程模型中,每个进程只有一个控制线程。这其实与基于线程的模型中每个进程只包含一个线程是相同的。程序开始运行时,它就是以单进程中单个控制线程启动的。在创建线程以前,程序和传统的进程没什么区别。创建线程通过调用pthread_create函数。这个函数的参数可以指定线程ID,指定线程的属性,指定线程要执行的函数的函数指针和参数列表。

线程的创建并不能保证哪一个线程会先运行,是新创建的线程还是调用线程,不能保证。

线程终止

单个线程可以通过以下三种方式之一退出,在不终止整个进程的情况下停止该线程的控制流:

  1. 该线程从启动例程中返回,返回值是退出码。
  2. 线程可以被同一进程的其他线程取消。函数是 pthread_cancel
  3. 线程调用pthread_exit

线程中的其他线程可以调用 pthread_join ,作用是:调用线程会一直阻塞,直到指定的线程调用pthread_exit或从启动例程中返回或被取消。

线程同步

如果每个线程使用的变量都是其他线程不会读写的,那么久不存在不一致问题,否则需要对线程进行同步设计,确保它们在访问时不会访问到无效的内容。为了解决这个问题,线程不得不使用锁,使得同一时间只允许一个线程访问某变量。

函数是 pthread_mutex_init/destroy/lock/trylock/unlock

重入

如果一个函数可以在同一时刻被多个线程安全地调用,就可以说该函数是线程安全的。

线程私有数据

线程特定数据,是存储和查询与某个线程相关的数据的一种机制。常见于,希望每个线程可以独立访问数据的副本而不用担心同步问题。

进程中的所有线程可以访问整个进程的整个地址空间。线程没有办法阻止其他线程访问它的数据,即使是私有数据也不例外。虽然底层实现并不能阻止这一切,但依然可以通过这种方法引入一定的数据独立性。

在分配私有数据前,需要创建一个与该数据关联的key,用这个key获得对私有数据的访问权。pthread_key_create,创建键之后,调用pthread_setspecific函数可以把键和私有数据关联起来,通过pthread_getspecific可以获得私有数据的地址。

线程与信号

信号的处理是进程中所有线程共享的。一个线程修改了对某个信号的处理方式后,所有的线程都必须共享这个行为的改变。

进程的信号是被传递到单个线程的。

要把信号发送到线程,用pthread_kill

线程I/O

pread函数和pwrite函数。

高级I/O

to be done

IPC