程序间交互和通信
系统级I/O
文件
- 普通文件:文本文件和二进制文件
- 目录包含一组链接的文件,每个链接都将文件映射到另一个文件。每个目录至少包含两个条目:’.’表示目录本身的链接和’..’表示父目录的链接
- 套接字是用来与另一个进程的进行跨网通信的文件
- 其他文件类型还包括命名通道、符号链接以及字符和块设备
打开和关闭文件
- open函数打开文件或者创建一个新文件,open将filename转换为一个文件描述符,并且返回文件描述符
fd=Open("foo.tst",O_RDONLY,0);
以只读的方式打开一个已存在的文件 - flag参数可以是一个或多个位的掩码的或,为写提供一些额外的指示
- O_CREAT表示如果文件不存在,就创建它的一个截断的空文件;O_TRUNC表示如果文件已经存在,就截断它;O_APPEND表示每次写操作前,设置文件位置到文件结尾处
fd=Open("foo.tst",O_WRONLY|O_APPEND,0)
- mode参数设置新文件的访问权限位
- 进程使用close关闭文件
读和写程序
程序通过read和write函数执行输入和输出
1
2
3
4
5
6
7
8
9
10
11#include <unistd.h>
ssize_t read(int fd,void *buf,size_t n);
//read函数从描述符为fd的当前文件复制最多n个字节到内存位置buf
//若成功则返回读的字节数,若EOF返回0,若失败返回-1
ssize_t write(int fd,const void *buf,size_t n);
//write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置
//成功返回写的字节数,失败返回-1
//read和write传送的字节比应用程序要求的少,不足值不代表错误
//如果打开的文件应用于网络套接字,内存缓冲约束和较长的网络延迟会引起read和write返回不足值通过调用lseek函数能够修改当前文件的位置
- 用RIO包读写(Robust I/O)
- 无缓冲的输入输出函数,直接在内存和文件之间读写文件
- 带缓冲的输入输出函数,缓存在应用级的缓冲区内
- 无缓冲的输入输出函数
1
2
3
4
5
6#include <"csapp.h">
ssize_t rio_readn(int fd,void *usrbuf,size_t n);
//从描述符为fd的当前文件复制最多n个字节到内存位置usrbuf
ssize_t rio_writen(int fd,void *usrbuf,size_t n);
//从内存位置usrbuf复制至多n个字节到描述符fd
//只有遇到EOF时才会返回不足值,对同一个描述符可以任意交错调用rio_readn和rio_writen的代码
5.带缓冲的输入输出函数,调用包装函数,从内部缓冲区复制一个文本行,当缓冲区变空时,自动调用read填满1
2
3
4
5
6
7
8
9
10#include <"csapp.h">
void rio_readinitb(rio_t *rp,int fd);
ssize_t rio_readlineb(rio_t fd,void *usrbuf,size_t maxlen);
//每打开一个描述符,都会调用一次rio_readinitb函数,将描述符fd和地址rp处的读缓冲区联系起来
//rio_readinitb函数从文件rp中读出下一个文本行(包括结尾的换行符),将它复制到内存为止,usrbuf用NULL结束这个文本行
//rio_readinitb函数最多读maxlen-1个字节,余下的一个字节留给NULL,超过的字符被截断
ssize_t rio_readnb(rio_t fd,void *usrbuf,size_t n);
//rio_readnb函数从文件rp中最多读n个字节到内存位置usrbuf
//对同一个描述符,可以任意交叉调用rio_readinitb和rio_readnb
读取文件元数据
- 程序调用stat和fstat函数,检索到关于文件的信息(文件元数据)
1
2
3
4
5
6
7#include <unistd.h>
#include <sys/stat.h>
int stat(const char *filename,struct stat *buf);
//以文件名作为输入,填写stat数据结构中的各个成员,Web服务器需要stat数据结构中的st_mode和st_size成员
int fstat(int fd,struct stat *buf);
//以文件描述符作为输入
读取目录内容
1 | //程序用readdir系列函数来读取目录的内容 |
共享文件
- 内核用三个相关的数据结构表示打开的文件:
- 描述符表:表项由进程打开的文件描述符索引的,,每个打开的描述符表项指向文件表中的一个表项
- 文件表:所有进程共享表示打开文件集合的文件表,每个文件表项组成包括当前文件位置、引用计数、以及指向v-node表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数,内核不会删除这个文件表表项,直到引用计数为0
- v-node表:所有进程共享,每个表项包含stat结构的大多数信息
- 多个描述符通过不同的文件表表项引用同一个文件,如以同一个filename调用open两次
文件共享
子进程继承父进程的打开文件
I/O重定向
- Linux shell提供了I/O重定向操作符,允许用户将磁盘文件和标准输入输出联系起来
ls >foo.txt #将标准输出重定向到磁盘文件
- I/O重定向通过dup2工作
1
2
3#include <unistd.h>
int dup2(int oldfd,int newfd);
//复制描述符表表项oldfd到描述符表表项newfd,覆盖newfd表项以前的内容,如果newfd已经打开了,则在oldfd复制之前关闭newfd
标准I/O
- 库libc提供了fopen、fclose、fread、fwrite、fgets、fputs、scanf和printf
- 将打开的文件模型化为一个流,类型为FILE的刘是对文件描述符和流缓冲区的抽象
选择合适的I/O
- 首选标准I/O
- 不要使用scanf和rio_readlineb来读二进制文件
- 对网络套接字的I/O使用RIO函数
- 标准I/O是全双工的,程序能够在同一个流上执行输入和输出
- 限制一:如果中间没有插入fflush、fseek、fsetpos和rewind的调用,输入函数不能跟在输出函数之后,fflush清空与流相关的缓冲区,后三个函数使用lseek函数重置当前文件位置。在每个输入操作前刷新缓冲区
- 限制二:如果中间没有插入fseek、fsetpos和rewind函数的调用,输出函数不能跟随在输入函数之后。对网络套接字使用lseek是非法的,对同一个打开的套接字打开两个流,一个用来写一个用来读
- 在网络套接字上不适用标准I/O,要使用RIO函数
网络编程
- 客户端和服务器运行在不同的主机上,通过计算机网络的硬件和软件资源来通信
- 网络是一种I/O设备,是数据源也是数据接收方,网络的物理接口,从网络上接收到的数据从适配器经过I/O和内存总线复制到内存,通过DMA传送,类似的数据也能从内存复制到网络
- 物理网络最底层LAN,最流行的局域网技术是以太网,集线器不加分辨的将从一个口收到的东西复制到其他每个口,每台主机都能看到每个位,每个以太网适配器都要有唯一的48位地址,存储在适配器的非易失性存储器上
- 主机可以发送帧到这个网段内的其他任何主机,每个帧都包含固定数量的头部,用来标识此帧的源和目的,此后紧跟的是数据位的有效载荷,每个主机适配器都可以看到这个帧,但是只有目的主机可以实际读取他
- 使用电缆和网桥可以将多个以太网段连接成较大的局域网,称为桥接局域网,网桥有选择地将帧从一个端口复制到另一个端口
- 多个不兼容的局域网可以通过路由器的特殊计算连接起来,组成互联网络
- 路由器连接到高速点到点电话连接,称为WAN广域网
- 用运行在每台主机和路由器上的软件协议,消除不同网络之间的差异,协议的两种基本能力:
- 命名机制,用一致的主机地址格式消除不兼容的局域网技术带来的差异,每台主机至少被分配一个互联网络地址,唯一的标识主机
- 传送机制,互联网协议定义一种将数据位捆扎成包(不连续的片)的统一方式消除差异。
- 包由包头和有效载荷组成,包头包含源主机和目的主机的地址,有效载荷包括从源主机发出的数据位
- 主机A传送数据到主机B的8个步骤如图所示
IP地址
- IP地址就是32位无符号整数(IPv4)
IP地址结构
1
2
3struct in_addr{
uint32_t s_addr;
};TCP/IP为任意整数数据项定义了统一的网络字节顺序(大端字节顺序),Unix使用函数在网络和主机字节顺序间实时转换
IP地址通常是点分十进制表示的,128.2.194.242就是地址0x8002c2f2。Linux系统中可以用一下命令来确定主机的点分十进制地址
hostname -i
并发编程
- 应用程序并发在以下几种情况是很有用的:访问慢速I/O设备、与人交互、通过推迟工作降低延迟、服务多个网络客户端、在多核机器上进行并行运算
- 三种基本构造并发程序的方法:进程、I/O多路复用、线程
基于进程的并发编程
- 在父进程接受客户端的连接请求,然后创建一个新的子进程来为每个新客户端提供服务
- 服务器监听一个监听描述符上的连接请求,然后假设服务器接受了客户端1的连接请求,并返回一个已连接描述符。在接受连接请求后,服务器派生子进程
- 这个子进程获得服务器描述表的完整副本,子进程关闭它的副本中的监听描述符3,父进程关闭已连接描述符4的副本,否则会引起内存泄露
- 包括SIGCHLD处理程序,回收僵死程序,当SIGCHLD处理程序执行时,SIGCHLD信号的阻塞的
- 父进程必须关闭它们各自的connfd副本
- 套接字的文件表表项中的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止
- 进程并发的劣势:
- 父进程和子进程共享状态信息,进程有一个清晰的模型,共享文件表,但是不共享用户地址空间
- 独立的地址空间使得进程共享状态信息变得困难,必须使用显式的IPC(进程间通信)机制进行共享信息
- 进程控制和IPC开销很高,运行很慢,套接字接口是IPC的一种重要形式,允许不同的主机上的进程交换任意的字节流,包括管道、先进先出、系统V共享内存以及系统V信号量
基于I/O多路复用的并发编程
- 服务器响应两个相互独立的I/O时间:1)网络客户端发起连接请求;2)用户在键盘键入命令行
使用select函数,要求挂起进程,只有在一个或多个I/O时间发生后,才将控制返回给程序
1
2
3
4
5
6
7
8
9
10
11
12
13//等待一组描述符准备好读
#include <sys/select.h>
int select(int n, fd_set *fdset, NULL, NULL, NULL);
FD_ZERO(fd_set *fdset);//创建于一个空的读集合
FD_CLR(int fd, fd_set *fdset);//
FD_SET(int fd, fd_set *fdset);//定义读集合
FD_ISSET(int fd, fd_set *fdset);//确定哪个描述符准备好读了
//fd_set集合是描述符集合
/*允许对描述符做三件事,分配它们,将此种类型的变量赋值给另一个变量,用FD_ZERO、FD_CLR、FD_SET和FD_ISSET宏来修改它们*/
/*select函数会一直阻塞,直到读集合中至少有一个描述符准备好可以读。从该描述符读取一个字节不会阻塞*/
/*函数修改参数fdset,返回值指明准备好集合的技术,每次调用select函数都会更新读集合*/一旦连接到某个客户端,就会连续回送输入行,直到客户端关闭连接中的一端
I/O复用的优势和劣势
- 优点:有更多的对程序行为的控制;运行在单一进程的上下文中,每个逻辑控制流都能访问该进程的全部地址空间;共享进程容易,可以用熟悉的调试工具调试并发服务器
- 事件驱动的缺点:编码复杂,代码比基于进程的服务器多3被;并发粒度减小,复杂性上升;在故意只发送部分文本然后停止的恶意客户端攻击前很脆弱;不能充分利用多核处理器
- 粒度是每个逻辑流每个事件片执行的指令数量
基于线程的并发编程
- 线程是运行在进程上下文中的逻辑流,线程由内核自动调度
- 每个线程都有自己的线程上下文,包括线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码
- 所有运行在一个进程中的线程共享该进程的整个虚拟空间
线程执行模型
- 每个进程开始都是单一线程,这个线程称为主线程
- 主线程创建一个对等线程,从这个时间点开始,两个进程并发地运行
- 主线程执行一个满系统调用,或者被系统的间隔计时器中断,控制会通过上下文切换传递到对等线程
- 线程的上下文比进程的上下文小很多,线程的上下文切换比进程的上下文切换快的多
- 主线程总是第一个运行的线程
- 和一个进程相关的进程组成一个对等线程池,独立于线程产生的线程
- 一个线程可以kill它的任何对等线程,或者等待它的任意对等线程终止
- 每个对等线程都能读写相同的共享数据
Posix线程
- Posix线程是C程序中处理线程的一个标准接口,定义了约60个函数,允许程序创建、杀死和回收线程,与对等线程安全的共享数据,通知对等线程系统状态的变化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20#include "csapp.h"
void *thread(void *vargp);
/*线程的本地代码被封装在一个线程例程中,每个线程例程都以一个通用指针作为输入,并返回一个通用指针*/
/*如果要传递多个参数给线程,将多个参数放在一个结构中,传递一个指向该结构的指针*/
int main()
{
pthread_t tid; /*tid存放对等线程的ID*/
Pthread_create(&tid, NULL, thread. NULL);
/*创建一个新的对等线程,主线程和新创建的对等线程同时运行,并且tid包含新线程的ID*/
Pthread_join(tid, NULL); /*主线程等待对等线程终止*/
exit(0); /*终止运行在这个进程中的所有线程*/
}
void *thread(void *vargp) /*定义对等线程*/
{
printf("hello,world!\n");
return NULL;
}
创建线程
- 线程通过调用pthread_create来创建其他线程
1
2
3
4
5
6
7
8#include <pthread.h>
typedef void *(func)(void *);
int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
/*输入变量arg,在新线程上下文中运行线程例程f*,能用attr参数来改变新创建线程的默认属性,tid包含线程ID/
#include <pthread.h>
pthread_t pthread_self(void); /*新线程调用pthread_self函数来获得自己的线程ID*/
终止线程
- 顶层的线程例程返回时,线程隐性终止
- 调用pthread_exit函数,线程会显式的终止,等待所有其他对等线程终止,再终止主线程和整个进程,返回值为thread_return
- 某个线程调用Linux的exit函数,函数终止该进程以及与该进程相关的线程
- 另一个对等线程通过当前进程ID作为参数调用pthread_cancel函数来终止当前线程
1 | #include <pthread.h> |
1 | //回收已终止线程的资源 |
分离线程
- 在任何一个时间点上线程是可结合的或者是分离的
- 可结合的线程能够被其他线程收回或杀死,在被其他线程回收前,它的内存资源不释放
- 分离的线程不能被其他线程回收或者杀死,内存资源在终止时被释放
- 默认情况下,线程被创建成可结合的
1
2
3
4
5#include <pthread.h>
int pthread_detach(pthread_t tid);
/*分离可结合线程tid*/
/*web浏览器的连接请求都创建一个新的对等线程,不需要显式的等待每个线程的终止*/
/*每个对等线程都应该在开始处理请求之前分离它自身,就能在终止后回收它的内存资源*/
初始化线程
- pthread_once函数允许初始化线程例程相关的状态
1
2
3
4
5#include <pthread.h>
pthread_once once_control=PITHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
/*once_control是一个全局变量或者静态变量,总是被初始化为PITHREAD_ONCE_INIT*/
/*第一次用参数once_control调用pthread_once时,调用init_routine*/
基于线程的并发服务器
- 主进程不断等待连接请求,创建一个对等线程处理该请求
- 将已连接描述符传递给对等线程,传递这个描述符的指针,但是这种对等线程的赋值语句和主线程的语句之间引入了竞争,将accept返回的每个已连接描述符分配到自己的动态分配内存块
- 为了避免内存泄露,必须分离每个线程,使得他终止时的每个内存资源能被收回
- 线性内存模型
- 一组并发线程运行在进程的上下文中,每个线程都有自己的上下文
- 每个线程和其他线程共享进程上下文的剩余部分,包括用户虚拟内存空间,是由只读文本、读写数据、堆以及所有共享库代码和数据区域组成的。线程也共享相同的打开文件合集
- 将变量映射到内存
- 全局变量:虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用
- 本地自动变量:是定义在函数内部,但是没有static属性的变量,在运行时,每个线程的栈都包含它自己所有自动变量的实例
- 本地静态变量:是定义在函数内部并且有static属性的变量,虚拟内存的读/写区域只包含每个静态变量的一个实例
6.共享变量,变量的一个实例被多个线程引用
用信号量同步线程
- 共享变量引入同步错误
- 两个对等线程在单处理器上同步运行,导致循环中的步骤错乱
进度图
- 进度图将指令模型化为一种状态到另一种状态的转换,转换被表示为有向线段
- 两条指令不能同时完成,不允许对角转换,不允许反向执行,不能向下或者向左
- 线程的临界区不允许和其他进程的临界区交替执行
- 保证每个线程在执行临界区时,有对共享内存变量的互斥访问
- 两个交界区形成的状态空间区域成为不安全区
- 绕开不安全区域的轨迹线叫做安全轨迹线
信号量
- 信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理P和V
- P(s):如果s非0,s-1,如果s为零,挂起这个线程,直到s为非0
- V(s):s+1,如果有操作阻塞在P等待s变成非0,V操作会重启线程中的一个,然后P将s-1
P和V保证正确初始化的信号量不会有一个负值,这个属性称为信号量不变性
1
2
3
4
5
6
7
8
9
10
11
12//POSIX标准定义了许多操作信号量的函数
#include <semaphore.h>
int sem_init(sem_t *sem, 0, unsigned int value); /*将信号量sem初始化为value*/
int sem_wait(sem_t *s); //执行P操作
int sem_post(sem_t *s); //执行V操作
//等价的P和V的包装函数
#include "csapp.h"
void P(sem_t *s);
void V(sem_t *s);使用信号量来实现互斥
- 用P(s)和V(s)将临界区包围起来,使用这种二元信号量来保护共享变量的信号量
- 提供互斥为目的的二元信号量通常也称信号锁,P是互斥锁加锁,V是互斥锁解锁
- 一个被用作一组可用资源计数的信号量被称为是计数信号量
- 使用s<0的状态建立一个禁止区,保证了临界区的互斥访问
利用信号量来调度共享资源
1 | typrdef struct{ |
- 读者-写者问题读者优先,如果不是使用权限赋给写,读操作不需要等待;写优先,在写者后面到达的读者必须等待
使用线程提高并行性
- 将序列分配成t个不想交的区域,然后给t个不同的线程分配区域
- 为了避免多核之间全局变量同步和PV操作造成的大延迟,在每个线程中使用私有变量/局部变量计算局部和,这个私有变量不和其他线程共享,不需要互斥锁来保护更新,也不需要每次循环都同步全局变量
- 并行程序加速比:Sp=T1/Tp
- 相关测量量-效率:Ep=Sp/p=T1/pTp,具有高效率的程序比低效率的程序在有用的工作上花费更多的时间
- 弱扩展:在增加处理器数量的同时,增加问题的规模
线程安全
- 线程安全被多个并发线程反复强调,一直产生安全的结果,否则这个函数就是线程不安全的
- 第1类:不保护共享变量的函数
- 第2类:保持跨越多个调用的状态的函数:单线程中使用另一个函数设置种子,多线程中不再使用任何static数据,重写函数编程可重入函数
- 第3类:指向静态变量的指针的函数:重写函数或者使用加锁-复制技术,定义线程安全的包装函数,通过调用包装函数来避免线程不安全函数的调用
- 调用线程不安全的函数:第2类只能重写函数编程可重入函数,第1、3类同第3种使用带有互斥锁的包装函数
- 可重入函数,一个重要的线程安全的函数,在被多个线程共享时,不会引用任何共享数据,是线程安全函数的一部分
- 没有同步操作,比不可重入函数更高效一点
- 只有值传递的函数是显式的可重入函数
- 值传递和引用传递的函数是隐式可重入函数
竞争
- 程序的正确性依赖于一个线程要在另一个线程到达y点之前到达x点,会引发竞争
- 为示例程序中的每个整数ID分配一个独立的块,并传递给线程例程必须释放这些块以避免内存泄露
死锁
- 一组线程被阻塞了
- 两个禁止区重叠部分的左下方,会导致死锁
- 程序员使用P和V操作顺序不当,所有阻塞的线程都在等待一个不会发生的V
- 死锁是不可预测的
- 使用二元信号量来实现互斥时,可以用互斥锁加锁顺序原则来避免死锁。给定所有互斥操作一个全序,如果每个线程都以一种顺序获得互斥锁并以相反的顺序释放,这种操作是无思索的