fork
SIGCHILD信号
// 创建一个子进程
pid_t fork(void); // pid_t类型表示进程ID, 但为了表示-1, 它是有符号整型. 0不是有效进程ID, init最小为1
// 失败: -1
// 成功: 通过返回值判断父子进程. (1) 父进程返回子进程的ID(非负值); (2) 子进程返回 0
// 注意: 不是fork函数能返回两个值, 而是fork后, fork函数变为两个, 父子需各自返回一个
// 阻塞函数, 调用一次回收一个子进程资源
pid_t wait(int *status);
// -1: 回收失败, 已经没有子进程了
// >0: 回收是子进程对应的pid
// status: 传出参数, 用于判断子进程终止原因. 可使用下面三组宏函数判断终止原因
// WIFEXITED(status)非0 --> 进程正常退出: return; exit;
// WEXITSTATUS(status)进一步获取进程退出状态
// WIFSIGNALED(status)非0 --> 进程异常终止
// WTERMSIG(status)取得使进程终止的那个信号的编号
// WIFSTOPPED(status)非0 --> 进程处于暂停状态
// WSTOPSIG(status)取得使进程暂停的那个信号的编号
// WIFCONTINUED(status)为真, 进程暂停后已经继续运行
// 一次只回收一个进程
pid_t waitpid(pid_t pid, int *status, int options);
// pid > 0: 某个子进程的pid
// pid == -1: 回收所有的子进程, 循环回收
// pid == 0: 回收当前进程组的所有子进程
// pid < -1: 回收当前进程组内的任意子进程, 取翻(加减号)
// status: 子进程的退出状态, 用法同wait函数
// opttions: 0, 阻塞; WNOHANG, 非阻塞
// 返回值:
// -1: 回收失败, 没有子进程
// >0: 被回收的子进程
// =0: 参3为WNOHANG, 且子进程正在运行
pid_t getpid(void); // 获取当前进程的ID
pid_t getppid(void); // 获取当前进程的父ID
uid_t getuid(void); // 获取当前进程实际用户ID
uid_t geteuid(void); // 获取当前用户有效用户ID
gid_t getgid(void); // 获取当前进程使用用户组ID
gid_t getegid(void); // 获取当前进程有效用户组ID
示例程序
普通全局变量没有共享, 但是地址空间相同: fork_1.c
孤儿进程: fork_2.c
僵尸进程: fork_3.c
pipe
作用于有血缘关系的进程之间,完成数据传递。调用pipe系统函数即可创建一个管道。有如下特质:
- 伪文件, 实质为内核缓冲区不占用磁盘空间
- 由两个文件描述符引用,一个表示读端,一个表示写端。 规定数据从管道的写端流入管道,从读端流出。 管道默认阻塞, 读写都为阻塞
- 操作管道的进程被销毁之后, 管道自动占用的存储空间自动被释放
管道的原理: 管道实为内核使用环形队列机制,借助内核缓冲区(4k)实现。
管道的局限性:
- 自己先读数据时, 不能自己写, 这时写端未关闭阻塞在读端
- 自己先写数据时, 可以自己读
- 数据一旦被读走,便不在管道中存在,不可反复读取。
- 由于管道采用半双工通信方式。因此,数据只能在一个方向上流动。要实现双工通信需建立两个管道
- 只能在有公共祖先的进程间使用管道。
使用管道需要注意以下4中特殊情况(假设都是阻塞I/O操作, 没有设置O_NONBLOCK标志):
- 如果所有指向管道写端的文件描述符都关闭(管道写端的引用计数为0), 而仍然有进程从管道的读端读数据, 那么管道中剩余的数据都被读取后, 再次read会返回0, 就像读到文件末尾一样
- 如果有指向管道写端的文件描述符没关闭(管道写端引用计数大于0), 而持有管道写端的进程也没有向管道中写数据, 这时有进程从管道读端读数据, 那么管道中剩余的数据都被读取后, 再次read会阻塞, 直到管道中有数据可读才读取数据并返回
- 如果所有指向管道读端的文件描述符都关闭(管道读端引用计数为0), 而持有管道写端的进程也没有向管道中写数据, 这时有进程向管道的写端write, 那么该进程会收到信号SIGPIPE, 通常会导致进程异常终止. 当然可以对SIGPIPE信号进行捕捉, 不终止进程
- 如果有指向管道读端的文件描述符没关闭(管道读端引用计数大于0), 而持有管道读端的进程也没有从管道中读数据, 这时有进程向管道写端写数据, 那么在管道被写满时再次write会阻塞, 直到管道中有空位置了才写入数据
总结:
读操作:
- 有数据: read(fd), 正常读, 返回读出的字节数
- 无数据: (1) 写端全部关闭, read解除阻塞, 返回0, 相当于读文件到尾部; (2) 没有全部关闭, read阻塞
写操作
- 读端全部关闭: 管道破裂, 进程被终止, 内核给当前进程发送SIGPIPE
- 读端没有全部关闭: (1) 缓冲区写满, write阻塞; (2) write继续写
优点: 简单, 相比信号和套接字实现进程间通信简单很多
缺点: (1) 只能单向通信, 双向通信需要建立两个管道; (2) 只能用于父子, 兄弟(有共同祖先)间通信. 可使用命名管道fifo解决
# include <unistd.h>
int pipe(int pipefd[2]);
// 函数调用成功后返回r/w两个文件描述符. 无须open, 但需手动close.
// 规定fd[0]读, fd[1]写. 向文件读写数据其实是在读写内核缓冲区
// 管道创建成功以后, 创建该管道的进程(父进程)同时掌握着管道的读端和写端. 采用以下步骤实现父子进程间的通信:
// 1. 父进程调用pipe函数创建管道, 得到两个文件描述符fd[0]和fd[1]指向管道的读端和写端
// 2. 父进程调用fork创建子进程, 那么子进程也有两个文件描述符指向同一管道
// 3. 父进程关闭管道读端, 子进程关闭管道写端. 父进程可以像管道中写入数据, 子进程将管道中的数据读出. 由于管道是利用环形队列实现的, 数据从写端流入管道, 从读端流出, 这样就实现了进程间通信
示例程序
#include <unistd.h>
long fpathconf(int fd, int name);
// 查看管道缓冲区大小:
// 命令: ulimit -a
// 函数: fpathconf
查看管道缓冲区: pipe_1.c
管道读写先后顺序: pipe_2.c
通过管道把ps结果发送至另一个进程执行grep: pipe_3.c
兄弟进程间通信, 父进程用于回收子进程: pipe_4.c
fifo
FIFO常被称为命名管道, 以区分管道(pipe). 管道(pipe)只能用于"有血缘关系"的进程间. 但通过FIFO, 不相关的进程也能交换数据
FIFO是linux基础文件类型中的一种. 但FIFO文件在磁盘上没有数据块, 仅仅用来标识内核中一条通道. 各个进程可以打开这个文件进程read/write, 实际上是在读写内核通道, 这样就实现了进程间的通信
特点: (1)伪文件, 在磁盘上大学为0; (2)在内核中有一个对应的缓冲区; (3)半双工的通信方式
使用场景: 没有血缘关系的进程间通信
创建方式: (1)命令: mkfifo管道名; (2) 函数: int mkfifo(const char *pathname, mode_t mode);
fifo文件可以使用IO函数进行操作: (1) open/close; (2)read/write; (3)不能执行lseek操作
进程间通信: 两个不相干的进程, 进程A(a.c)和进程B(b.c) a.c和b.c中open的文件需要打开一个fifo
a.c:
int fd = open("myfifo", O_RDONLY);
read(fd, buf, sizeof(buf));
close(fd);
b.c
int fd = open("myfifo", O_WRONLY);
write(fd, "Hello World", 11);
close(fd)
示例程序
测试时要先关闭读端再关闭写端, 否则写端关闭后读端一直读0, 与套接字有点像
写端: fifo_write.c
读端: fifo_read.c
消息队列
那我们能不能把进程的数据放在某个内存之后就马上让进程返回呢?无需等待其他进程来取就返回呢?
答是可以的,我们可以用消息队列的通信模式来解决这个问题,例如a进程要给b进程发送消息,只需要把消息放在对应的消息队列里就行了,b进程需要的时候再去对应的消息队列里取出来。同理,b进程要个a进程发送消息也是一样。这种通信方式也类似于缓存
这种通信方式有缺点吗?答是有的,如果 a 进程发送的数据占的内存比较大,并且两个进程之间的通信特别频繁的话,消息队列模型就不大适合了。因为 a 发送的数据很大的话,意味发送消息(拷贝)这个过程需要花很多时间来读内存。
哪有没有什么解决方案呢?答是有的,请继续往下看。
mmap
存储映射I/O(Memory-mapped I/O)使一个磁盘文件与存储空间中的一个缓冲区相映射. 于是当从缓冲区中取数据, 就相当于读文件中的相应字节. 与此类似, 将数据存入缓冲区, 则相应的字节就自动写入文件. 这样, 就可在不使用read和write函数的情况下, 使用地址(指针)完成I/O操作
使用这种方法, 首先应通知内核, 将一个指定文件映射到存储区域中. 这个映射工作可以通过mmap函数来实现
作用: 将磁盘文件的数据映射到内存, 用户通过修改内存就能修改磁盘文件
这个可能有人会问了, 每个进程不是有自己的独立内存吗? 两个进程怎么就可以共享一块内存了?
我们都知道, 系统加载一个进程的时候, 分配给进程的内存并不是实际物理内存, 而是虚拟内存空间. 可以让两个进程各自拿出一块虚拟地址空间来, 然后映射到相同的物理内存中, 这样, 两个进程虽然有着独立的虚拟内存空间, 但有一部分却是映射到相同的物理内存, 这就完成了内存共享机制了
void *mmap( // 成功:返回创建的映射区首地址;失败:MAP_FAILED宏
void *addr, // 建立映射区首地址, 由linux内核指定, 传NULL
size_t length, // 映射区的大小, 4k的整数倍, 不能为0, 一般文件有多大length就为多大
int prot, // 映射区的权限, PROT_READ(映射区 必须 要有读权限), PROT_WRITE
int flags, // 标志位参数, MAP_SHARED(数据同步到磁盘), MAP_PRIVATE(数据不会同步到磁盘), 有血缘关系通信需是MAP_SHARED
int fd, // 文件描述符, 要映射文件对应的fd(open得来的)
off_t offset // 映射文件的偏移量, 映射时文件指针的偏移量, 必须是4k的整数倍, 一般为0
);
// 同malloc函数申请内存空间类似的,mmap建立的映射区在使用结束后也应调用类似free的函数来释放
int munmap( // 成功:0; 失败:-1
void *addr, // mmap的返回值
size_t length // mmap的返回值
);
思考问题
如果对mmap的返回值(ptr)做++操作(ptr++), munmap是否能够成功?
不能对ptr做操作, 可以复制一份, 对复制的指针进行操作
如果open是O_RDONLY, mmap时prot参数指定PROT_READ|PROT_WRITE会怎样
mmap调用失败, open文件指定权限应该大于等于mmap第三个参数prot指定的权限
如果文件偏移量为1000会怎样
必须是4096的整数倍
如果不检测mmap的返回值会怎么样
没什么影响
mmap什么情况下会调用失败
第二个参数length=0(没有数据, 无论MAP_SHARED还是MAP_PRIVATE都会出错); 第三个参数没有指定PROT_READ; 第五个参数fd对应的open权限必须大于port权限; offset必须是4096的整数倍
可以open的时候O_CREAT一个新文件来创建映射区吗
可以, 需要做文件拓展(lseek, truncate)
mmap后关闭文件描述符, 对mmap映射有无影响
没有影响
对ptr越界操作会怎样
段错误
总结:使用mmap时务必注意以下事项:
- 创建映射区的过程中,隐含着一次对映射文件的读操作。
- 当MAP_SHARED时,要求:映射区的权限应 <=文件打开的权限(出于对映射区的保护)。而MAP_PRIVATE则无所谓,因为mmap中的权限是对内存的限制。
- 映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。
- 特别注意,当映射文件大小为0时,不能创建映射区。所以:用于映射的文件必须要有实际大小!! mmap使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。
- munmap传入的地址一定是mmap的返回地址。坚决杜绝指针++操作。
- 如果文件偏移量必须为4K的整数倍
- mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。
父子进程间通信
父子等有血缘关系的进程之间也可以通过mmap建立的映射区来完成数据通信。但相应的要在创建映射区的时候指定对应的标志位参数flags:
- MAP_PRIVATE: (私有映射) 父子进程各自独占映射区;
- MAP_SHARED: (共享映射) 父子进程共享映射区;
匿名映射
通过使用我们发现, 使用映射区来完成文件读写操作十分方便, 父子进程间通信也较容易. 但缺陷是, 每次创建映射区一定要依赖一个文件才能实现. 通常为了建立映射区要open一个temp文件, 创建好了再unlink, close掉, 比较麻烦. 可以直接使用匿名映射来代替. 其实Linux系统给我们提供了创建匿名映射区的方法,无需依赖一个文件即可创建映射区。同样需要借助标志位参数flags来指定
使用MAP_ANONYMOUS (或MAP_ANON), 如: int *p = mmap(NULL, 4/*举例, 根据需求填写*/, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
需注意的是, MAP_ANONYMOUS和MAP_ANON这两个宏是Linux操作系统特有的宏. 在类Unix系统中如无该宏定义, 可使用如下两步来完成匿名映射区的建立
fd = open("/dev/zero", O_RDWR);
p = mmap(NULL, size, PROT_READ|PROT_WRITE, MMAP_SHARED, fd, 0);
mmap无血缘关系进程间通信
实质上mmap是内核借助文件帮我们创建了一个映射区, 多个进程之间利用该映射区(需借助文件)完成数据传递. 由于内核空间多进程共享, 因此无血缘关系的进程间也可以使用mmap来完成通信. 只要设置相应的标志位参数flags即可. 若想实现共享, 当然应该使用MAP_SHARED了
进程通信总结
进程间通信, 不阻塞, 数据直接才内存中处理,
有血缘关系,
父子进程共享内存映射区, 可以创建匿名映射区, 可以不需要磁盘文件进行通信
没有血缘关系
不能使用匿名映射的方式, 只能借助磁盘文件创建映射区, 需打开相同的文件描述符
A(a.c) B(b.c)
a.c: int fd1 = open("XXX"); void *ptr = mmap(,,,,fd1, 0);
对映射区(ptr)进行读写操作
b.c: int fd2 = open("XXX"); void *ptr = mmap(,,,,fd2, 0);
对映射区(ptr)进行写操作
示例程序
利用内存映射区读文件: mmap_1.c
MAP_PRIVATE与MAP_SHARED测试: mmap_2.c
父子进程共享:
- 打开的文件
- mmap建立的映射区(但必须要使用MAP_SHARED)
有血缘关系匿名映射区通信: mmap_3.c
无血缘关系利用内存映射区通信: (1)读端: mmap_4_read.c; (2)写端: mmap_4_write.c
signal
共享内存最大的问题是什么?没错,就是多进程竞争内存的问题,就像类似于我们平时说的线程安全问题。如何解决这个问题?这个时候我们的信号量就上场了。
信号量的本质就是一个计数器,用来实现进程之间的互斥与同步。例如信号量的初始值是 1,然后 a 进程来访问内存1的时候,我们就把信号量的值设为 0,然后进程b 也要来访问内存1的时候,看到信号量的值为 0 就知道已经有进程在访问内存1了,这个时候进程 b 就会访问不了内存1。所以说,信号量也是进程之间的一种通信方式。
信号函数
//给自己发送异常终止信号, 没有参数返回值, 永远不会调用失败
int raise(int sig);
// 该函数无返回值, 异常终止一个进程, 同时发送SIGABRT信号给调用进程
void abort(void);
// 设定定时器(每个进程**只有一个定时器**), 使用的是自然定时法, 时间运行不受当前进程的影响
unsigned int alarm( // 返回0或剩余的秒数, 无失败
unsigned int seconds); // 当时间到达之后, 函数发出一个信号SIGALRM, 0表示取消闹钟设置
int getitimer(int which, struct itimerval *curr_value);
int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value); // 定时器, 实现周期性定时
// which: 使用哪种定时器, 有三种定时器, 不同定时器的信号不同
// 自然定时: ITIMER_REAL: 发送4号SIGLARM; 计算自然时间
// 虚拟空间计时(用户空间): ITIMER_VIRTUAL 发送26号SIGVTALRM; 只计算进程占用cpu的时间
// 运行时计时(用户+内核): ITIMER_PROF, 发送27号SIGPROF; 计算占用cpu及执行系统调用的时间
// old_value: 传出参数, 上一次设置定时器信息, 一般设置为NULL
struct itimerval {
struct timeval it_interval; // 定时周期
struct timeval it_value; // 第一次触发定时器的时间
};
struct timeval { // 两个值是相加的关系, 两个变量都需赋值, 否则是垃圾值
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
信号的特质: 由于信号是通过软件方法实现, 其实现手段导致信号有很强的延时性. 但对于用户来说, 这个延迟时间非常短, 不易察觉
每个进程收到的所有信号,都是由内核负责发送的,内核处理。
信号的状态: (1) 产生; (2) 未决状态, 产生和递达之间的状态。主要由于阻塞(屏蔽)导致该状态; (3) 递达, 递送并且到达进程
信号处理方式: (1) 执行默认动作; (2) 忽略(丢弃); (3) 捕捉(调用户处理函数)
每个信号也有其必备4要素, 分别是: (1)编号 (2)名称 (3)事件 (4)默认处理动作
默认动作:
- Term:终止进程
- Ign: 忽略信号(默认即时对该种信号忽略操作)
- Core: 终止进程, 生成核心转储文件Core文件(查验进程死亡原因, 用于gdb调试)
- Stop: 停止(暂停)进程
- Cont: 继续运行进程
SIGKILL和SIGSTOP信号, 不允许忽略和捕捉, 只能执行默认动作, 甚至不能将其设置为阻塞
另外需清楚, 只有每个信号所对应的事件发生了, 该信号才会被递送(但不一定递达), 不应乱发信号
int kill(pid_t pid, int sig);
// pid > 0: 信号发送给PID为pid的进程
// pid = 0: 信号发送给本进程
// pid = -1: 信号发送给除init进程外的所有进程, 但发送者需要拥有对目标进程发送该信号的权限
// pid < -1: 信号发送给组ID为-pid的进程组中的所有进程
// 该函数由ANSI定义, 由于历史原因在不同版本的Unix和不同版本的Linux中可能有不同的行为. 因此应该尽量避免使用它, 取而代之使用sigaction函数
typedef void (*sighandler_t)(int);
sighandler_t signal( // 系统调用出错时返回SIG_ERR, 并设置errno
int signum, // 捕捉的信号
sighandler_t handler); // 某些操作系统中行为可能不同
int sigaction(
int signum, // 捕捉的信号
const struct sigaction *act, // 指定新的信号处理方式
struct sigaction *oldact); // 输出信号先前的处理方式
struct sigaction {
void (*sa_handler)(int); // 传入参数, 指定信号处理函数
void (*sa_sigaction)(int, siginfo_t *, void *); // 一般不使用
sigset_t sa_mask; // 设置进程的信号掩码(确切说是在进程原有的信号掩码的基础上增加信号掩码), 以指定哪些信号不能发送给本进程
int sa_flags; // 使用sa_handler-->0, 设置程序接收到信号时的行为
void (*sa_restorer)(void); // 被废弃, 不再使用
};
// 重点掌握
// 1. sa_handler: 指定信号捕捉后的处理函数名(即注册函数)。也可赋值为SIG_IGN表忽略或 SIG_DFL表执行默认动作
// 2. sa_mask: 调用信号处理函数时,所要屏蔽的信号集合(信号屏蔽字)。注意:仅在处理函数被调用期间屏蔽生效,是临时性设置。
// 3. sa_flags: 通常设置为0,表使用默认属性。
信号集概念: 信号产生处于未决状态, 进程收到信号之后, 信号被放入未决信号集; 放入未决信号集的信号等待处理之前, 先判断阻塞信号集中该信号对应的标志位是否为1(不处理), 0为处理; 当阻塞信号集对应的标准位为0时, 该信号被处理
- 未决信号集: 信号产生, 未决信号集中描述该信号的位立刻翻转为1, 表信号处于未决状态. 当信号被处理对应位翻转回为0. 这一时刻往往非常短暂. 在屏蔽解除前, 信号一直处于未决状态
- 阻塞信号集: 将某个信号放到阻塞信号集, 这个信号就不会被进程处理; 阻塞接触之后, 信号被处理
- 自定义信号集: 通过自定义信号集来设置内核中的信号集
设置进程信号掩码后, 被屏蔽的信号将不能被进程接收. 如果给进程发送一个被屏蔽的信号, 则操作系统将该信号设置为进程的一个被挂起的信号. 如果取消对被挂起信号的屏蔽, 则它能立即被进程接收到
进程即使多次接收到同一个被挂起的信号, sigpending函数也只能反应一次; 并且当再次使用sigprocmask使能该挂起的信号时, 该信号的处理函数也只能处理一次
在多进程, 多线程环境中, 要以进程, 线程为单位来处理信号和信号掩码. 不能设想新创建的进程, 线程具有和父进程, 主线程完全相同的信号特征. 比如fork调用产生的子进程将继承父进程的信号掩码, 但是具有一个空的挂起信号集
int sigemptyset(sigset_t *set); // 将set集合置空
int sigfillset(sigset_t *set); // 将所有信号加入set集合
int sigaddset(sigset_t *set, int signum); // 将signum信号加入set集合
int sigdelset(sigset_t *set, int signum); // 从set集合中溢出signo信号
int sigismember(const sigset_t *set, int signum); // 判断信号是否存在信号集中
// 屏蔽and接触信号屏蔽, 将自定义信号集设置给阻塞信号集
int sigprocmask( //
int how, // 可选三个参数: SIG_BLOCK(mask = mask | set), SIG_UNBLOCK(mask & ~mask), SIG_SETMASK(mask = mask)
const sigset_t *set, // 如果set不为NULL, 则how参数指定设置进程信号掩码方式, 为NULL则进程信号掩码不变
sigset_t *oldset); // 获得当前信号集的掩码
// 获得进程当前被挂起的信号集
int sigpending(sigset_t *set); // 将内核的未决信号集写入set
示例程序
kill用法: signal_kill.c
abort用法: signal_abort.c
alarm用法: signal_alarm.c
测试一秒钟程序能数多少数字: signal_test_1.c
setitimer使用: signal_setitimer.c
signal: signal.c
sigaction信号屏蔽: signal_sigaction.c
查看内核中的未决信号集: signalset_1.c
信号实时捕捉: signalset_2.c
总结
- 管道 --> 效率低下
a进程给b进程传递数据, 只能等待b进程取了数据之后才能返回. 不适合频繁通信的进程, 比较简单,能够保证我们的数据已经真的被其他进程拿走了 - 消息队列 --> 把进程的数据放在某个内存之后就马上让进程返回. 带来内存拷贝问题
a进程发送的数据占的内存比较大,并且两个进程之间的通信特别频繁的话,消息队列模型就不大适合了。因为 a 发送的数据很大的话,意味发送消息(拷贝)这个过程需要花很多时间来读内存 - 共享内存 --> 解决拷贝问题. 带来内存竞争问题
系统加载一个进程的时候,分配给进程的内存并不是实际物理内存,而是虚拟内存空间。那么我们可以让两个进程各自拿出一块虚拟地址空间来,然后映射到相同的物理内存中,这样,两个进程虽然有着独立的虚拟内存空间,但有一部分却是映射到相同的物理内存,这就完成了内存共享机制了。 - 信号量 --> 共享内存最大问题是多进程竞争内存的问题, 类似线程安全, --> 信号量
信号量的本质就是一个计数器,用来实现进程之间的互斥与同步。例如信号量的初始值是 1,然后 a 进程来访问内存1的时候,我们就把信号量的值设为 0,然后进程b 也要来访问内存1的时候,看到信号量的值为 0 就知道已经有进程在访问内存1了,这个时候进程 b 就会访问不了内存1。所以说,信号量也是进程之间的一种通信方式。 - socket --> 共享内存、管道、信号量、消息队列,他们都是多个进程在一台主机之间的通信,那两个相隔几千里的进程能够进行通信吗
进程组
进程组,也称之为作业。BSD于1980年前后向Unix中增加的一个新特性。代表一个或多个进程的集合。每个进程都属于一个进程组。在waitpid函数和kill函数的参数中都曾使用到。操作系统设计的进程组的概念,是为了简化对多个进程的管理。
当父进程,创建子进程的时候,默认子进程与父进程属于同一进程组。进程组ID第一个进程ID(组长进程)。所以,组长进程标识:其进程组ID其进程ID
可以使用kill -SIGKILL -进程组ID(负的)来将整个进程组内的进程全部杀死。
组长进程可以创建一个进程组,创建该进程组中的进程,然后终止。只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关。
进程组生存期:进程组创建到最后一个进程离开(终止或转移到另一个进程组)。
一个进程可以为自己或子进程设置进程组ID
总结:
进程的组长: 组里边的第一进程; 进程组的ID--> 进程组的组长的ID
进程组组长的选择: 进程中的第一个进程
进程组ID的设定: 进程组的id就是组长的进程ID
pid_t getpgrp(void); // 获取当前进程的进程组ID, 总是返回调用者的进程组ID
pid_t getpgid(pid_t pid); // 获取指定进程的进程组ID
// 如果pid = 0, 那么该函数作用和getpgrp一样。
int setpgid(pid_t pid, pid_t pgid); // 将参1对应的进程,加入参2对应的进程组中
// 改变进程默认所属的进程组. 通常可用来加入一个现有的进程组或创建一个新进程组
// 注意:
// 1. 如改变子进程为新的组, 应fork后, exec前。
// 2. 权级问题. 非root进程只能改变自己创建的子进程, 或有权限操作的进程
示例程序
杀死进程组内的全部进程: kill_multprocess.c
查看进程对应的进程组ID: getpgid.c
修改子进程的进程组ID: setpgid.c
会话
会话: 多个进程组
创建一个会话需要注意以下6点注意事项:
- 调用进程不能是进程组组长, 该进程变成新会话首进程(session header)
- 该进程成为一个新进程组的组长进程
- 需有root权限(ubuntu不需要)
- 新会话丢弃原有的控制终端, 该会话没有控制终端
- 该调用进程是组长进程, 则出错返回
- 建立新会话时, 先调用fork, 父进程终止, 子进程调用setsid
ps ajx命令查看系统中的进程。参数a表示不仅列当前用户的进程,也列出所有其他用户的进程,参数x表示不仅列有控制终端的进程,也列出所有无控制终端的进程,参数j表示列出与作业控制相关的信息。
pid_t getsid(pid_t pid); // 获取进程所属的会话ID
// pid为0表示察看当前进程session ID
// 组长进程不能成为新会话首进程,新会话首进程必定会成为组长进程。
pid_t setsid(void); // 创建一个会话,并以自己的ID设置进程组ID,同时也是新会话的ID。
// 调用了setsid函数的进程,既是新的会长,也是新的组长
示例程序
fork一个子进程,并使其创建一个新会话。查看进程组ID、会话ID前后变化: session.c
守护进程
Daemon(精灵)进程, 是Linux中的后台服务进程, 通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件. 一般采用以d结尾的名字
Linux后台的一些系统服务进程, 没有控制终端, 不能直接和用户交互. 不受用户登录和注销的影响, 一直在运行着, 他们都是守护进程. 如: 预读入缓输出机制的实现; ftp服务器; nfs服务器等
创建守护进程,最关键的一步是调用setsid函数创建一个新的Session,并成为Session Leader。
守护进程的特点
- 后台服务程序
- 独立于终端控制
- 周期性执行某任务
- 不受用户登陆注销影响
- 一般采用以d结尾的名字(服务)
创建守护进程模型
- fork子进程, 父进程退出, 所有工作在子进程中进行形式上脱离了控制终端; 必须
- 子进程创建新会话, setsid函数, 使子进程完全独立出来, 脱离控制; 必须
- 改变当前目录为根目录, chdir()函数, 防止占用可卸载的文件系统, 也可以换成其它路径, 为了增强程序的健壮性; 非必须
- 重设文件权限掩码, umask()函数, 防止继承的文件创建屏蔽字拒绝某些权限, 增加守护进程灵活性; 非必须
- 关闭文件描述符, 继承的打开文件不会用到, 浪费系统资源, 无法卸载, close(0), close(1), close(2); 非必须
- 执行核心工作
- 守护进程退出处理程序模型;
示例程序
创建守护进程: mydaemond.c
pthread
线程的分离状态决定一个线程以什么样的方式来终止自己。
- 非分离状态:线程的默认属性是非分离状态,这种情况下,原有的线程等待创建的线程结束。只有当pthread_join()函数返回时,创建的线程才算终止,才能释放自己占用的系统资源。
- 分离状态:分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。应该根据自己的需要,选择适当的分离状态。
这里要注意的一点是,如果设置一个线程为分离线程,而这个线程运行又非常快,它很可能在pthread_create函数返回之前就终止了,它终止以后就可能将线程号和系统资源移交给其他的线程使用,这样调用pthread_create的线程就得到了错误的线程号。要避免这种情况可以采取一定的同步措施,最简单的方法之一是可以在被创建的线程里调用pthread_cond_timedwait函数,让这个线程等待一会儿,留出足够的时间让函数pthread_create返回。设置一段等待时间,是在多线程编程里常用的方法。但是注意不要使用诸如wait()之类的函数,它们是使整个进程睡眠,并不能解决线程同步的问题。
失败返回错误号, 不能用perror打印错误信息, 使用strerror(ret)
安装线程man page,命令:sudo apt-get install manpages-posix-dev
安装完成,使用man -k pthread
如能看到线程函数列表则表明安装成功。
int pthread_create(
pthread_t *thread, // 传出参数, 保存系统分配的ID
const pthread_attr_t *attr, // 线程属性, 通常传NULL. 默认父子进程不分离, 主线程要手动释放子进程pcb, 即使用pthread_join
void *(*start_routine) (void *), // 子线程的处理函数
void *arg); // 回调函数的参数
pthread_t pthread_self(void);
// 返回线程ID, 在linux下是无符号整数(%lu), 在其他系统中可能是结构体实现
// 线程ID是内部标识, 两个进程间, 线程ID可以相同
// pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了
void pthread_exit(
void *retval); // 必须指向全局或堆的地址空间, 不能是一个栈地址
// 单个线程退出, 主线程执行pthread_exit不影响子线程的执行
// 主线程执行exit或return, 则子线程都会退出
// 子线程执行exit, 所有线程(包括主线程)都会推出**
// 子线程执行return和pthread_exit都是退出当前线程
// 阻塞等待线程退出, 获取线程退出状态
int pthread_join(
pthread_t thread, // 要回收的子线程的线程id, 不是指针
void **retval); // 读取线程退出的时候携带的状态信息, 指向的内存和pthread_exit或return参数指向的内存一样; 如果线程使用pthread_cancel异常终止, reval所指向的单元里存放常数PTHREAD_CANCELED
int pthread_detach(pthread_t thread); // 线程分离
// 调用该函数之后不需要pthread_join, 子线程会自动回收自己的pcb, 一般在创建线程时设置分离属性
// 进程若有该机制,将不会产生僵尸进程。
// 僵尸进程的产生主要由于进程死后,大部分资源被释放,一点残留资源仍存于系统中,导致内核认为该进程仍存在
int pthread_cancel(pthread_t thread); // 杀死(取消)线程, 可杀死分离的线程
// 使用注意事项: 在要杀死的子线程对应的处理的函数的内部, 必须做过一次系统调用(使用类似printf的函数), 即不是实时的杀死线程;
// 可以使用pthread_testcancel()来添加取消点
int pthread_equal(pthread_t t1, pthread_t t2);
// 比较两个线程ID是否相等(预留函数)
// 线程属性操作函数
int pthread_attr_init(pthread_attr_t *attr); // 对线程属性变量的初始化
int pthread_attr_setdetachstate( // 设置线程分离属性
pthread_attr_t *attr, // attr: 线程属性
int detachstate); // detachstate: PTHREAD_CREATE_DETACHED(分离), PTHREAD_CREATE_DETACHED(非分离)
int pthread_attr_destroy(pthread_attr_t *attr); // 释放线程资源
使用注意事项:
- 主线程退出其他线程不退出, 主线程应调用pthread_exit
- 避免僵尸线程: pthread_join, pthread_detach, pthread_create指定分离属性
- malloc和mmap申请的内存可以被其他线程释放
- 应避免在多线程模型中调用fork, 除非马上exec, 子进程中只有调用fork的线程存在, 其他线程在子进程中均pthread_exit-->不太懂
- 信号的复杂语义很难和多线程共存, 应避免在多线程引入信号机制
实例程序
输出顺序不确定: pthread_1.c
传值与传地址: pthread_2.c
主线程使用pthread_exit退出, 子线程正常执行: pthread_3.c
join与pthread_exit
没有return返回和return NULL结果一样都是core dumped
使用return &number
和pthread_exit(&number)
时, 主线程的pthread_join(thid, (void**)&ptr);
都可以收到number的值
pthread_4.c
当线程A数的数字还没有写入内存中时, 就已经失去cpu
当线程B数的数字同步到内存中时, 会导致共享的数据混乱
pthread_5.c
使用pthread_cancel杀死分离的线程: pthread_6.c
线程池: threadpool.h threadpool.c
lock
rwlock
一把读写锁具备三种状态:
- 读模式下加锁状态 (读锁)
- 写模式下加锁状态 (写锁)
- 不加锁状态
读写锁特性:
- 读写锁是“写模式加锁”时, 解锁前,所有对该锁加锁(读或写操作)的线程都会被阻塞。
- 读写锁是“读模式加锁”时, 如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞。
- 读写锁是“读模式加锁”时, 既有试图以写模式加锁的线程,也有试图以读模式加锁的线程。那么读写锁会阻塞随后的读模式锁请求。优先满足写模式锁。读锁、写锁并行阻塞,写锁优先级高
读写锁也叫共享-独占锁。当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。写独占、读共享。
读写锁非常适合于对数据结构读的次数远大于写的情况。
读写锁的特性:
- 线程A加读锁成功, 又来了三个线程, 做读操作, 可以加锁成功. 读共享, 并行处理
- 线程A加写锁成功, 又来了三个线程, 做读操作, 三个线程阻塞. 写读占, 串行处理
- 线程A加读锁成功, 又来了B线程加写锁阻塞, 在B之后来了C线程加读锁阻塞. 读写不能同时进行, 写的优先级高
(4) 读写锁场景练习
线程A加写锁成功, 线程B请求读锁:
线程B阻塞
线程A持有读锁, 线程B请求写锁:
线程B阻塞
线程A拥有读锁, 线程B请求读锁
线程B加锁成功
线程A持有读锁, 然后线程B请求写锁, 然后线程C请求读锁
B阻塞, C阻塞 --> 写的优先级高
A解锁, B线程加写锁成功, C继续阻塞
B解锁, C加读锁成功
线程A持有写锁, 然后线程B请求读锁, 然后线程C请求写锁
B, C阻塞
A解锁, C加写锁, B阻塞
C解锁, B加锁成功
读写锁 pthread_rwlock_t 的初始化方法同互斥量变量 pthread_mutex_t 的初始化方法相类似,分为静态初始化和动态初始化
其中静态初始化方法常常用在 pthread_rwlock_t 被设定为 static 静态变量或是 extern 全局变量的时候所使用,因为静态变量与全局变量在编译的时候就已经被系统分配完毕,所以这些类型的变量空间并不是在程序运行的时候动态分配的。无法通过调用相关方法来为其划分空间,只能够借助于宏定义实现的初始化手段来为其初始化。该初始化方法,将会为创建出来的 pthread_rwlock_t 对象统一的进行赋值默认的数值。其中,为其分配的空间来自于系统中的栈空间,在程序结束之后,栈空间会自动被系统所回收。
即 pthread_rwlock_t data = PTHREAD_RWLOCK_INITIALIZER;
而动态初始化方法常常用在 pthread_rwlock_t 变量在系统中的空间是动态划分的,也就是系统通过malloc,realloc,在堆空间中为变量划分空间。我们都知道:从堆中申请的空间,在程序结束之后是不会被系统自动回收的,是通过使用 pthread_rwlock_destory 方法手动的将空间进行释放
(5) 读写锁使用场景
互斥锁--> 读写串行
读写锁--> 读: 并行; 写: 串行
程序中读操作大于写操作
读写锁和互斥锁, 并不是任何时候都能阻塞线程
int pthread_rwlock_init(
pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr); // 通常使用默认属性,传NULL即可
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock); // 销毁读写锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); // 请求读锁
// 阻塞: 之前对这把锁加的写锁的操作
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); // 请求写锁
// 阻塞: 上一次加写锁还没有解锁; 上一次加读锁没解锁
// 成功: 0; 加锁失败: 错误号
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); // 非阻塞请求读锁
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); // 非阻塞请求写锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); // 解锁
示例程序
mutex
每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。
资源还是共享的,线程间也还是竞争的,但通过“锁”就将资源的访问变成互斥操作,而后与时间有关的错误也不会再产生了。
但,应注意:同一时刻,只能有一个线程持有该锁。当A线程对某个全局变量加锁访问
- B在访问前尝试加锁,拿不到锁,B阻塞。
- C线程不去加锁,而直接访问该全局变量,依然能够访问,但会出现数据混乱。
所以,互斥锁实质上是操作系统提供的一把“建议锁”(又称“协同锁”),建议程序中有多线程访问共享资源的时候使用该机制。但,并没有强制限定。
因此,即使有了mutex,如果有线程不按规则来访问数据,依然会造成数据混乱。
int pthread_mutex_init(
// restrict关键字:只用于限制指针,告诉编译器,所有修改该指针指向内存中内容的操作,只能通过本指针完成。不能通过除本指针以外的其他变量或指针修改
pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr); // 是一个传入参数,通常传NULL,选用默认属性(线程间共享)
// 1. 静态初始化:如果互斥锁 mutex 是静态分配的(定义在全局,或加了static关键字修饰),可以直接使用宏进行初始化。e.g. pthead_mutex_t muetx = PTHREAD_MUTEX_INITIALIZER;
// 2. 动态初始化:局部变量应采用动态初始化。e.g. pthread_mutex_init(&mutex, NULL)
int pthread_mutex_destroy(pthread_mutex_t *mutex); // 销毁互斥锁
int pthread_mutex_lock(pthread_mutex_t *mutex); // 加锁。可理解为将mutex--(或-1)
// 加锁失败会阻塞,等待锁释放。
int pthread_mutex_trylock(pthread_mutex_t *mutex);
// trylock加锁失败直接返回错误号(如:EBUSY),不阻塞
int pthread_mutex_unlock(&mutex); // 解锁。可理解为将mutex ++(或+1)
// unlock主动解锁函数,同时将阻塞在该锁上的所有线程全部唤醒,至于哪个线程先被唤醒,取决于优先级、调度。默认:先阻塞、先唤醒。
mutex进程间同步
进程间也可以使用互斥锁,来达到同步的目的。但应在pthread_mutex_init初始化之前,修改其属性为进程间共享。mutex的属性修改函数主要有以下几个。
pthread_mutexattr_t mattr // 用于定义mutex锁的【属性】
int pthread_mutexattr_init(pthread_mutexattr_t *attr); // 初始化一个mutex属性对象
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr); // 销毁mutex属性对象 (而非销毁锁)
int pthread_mutexattr_setpshared( // 修改mutex属性。
pthread_mutexattr_t *attr,
int pshared); // 线程锁:PTHREAD_PROCESS_PRIVATE (mutex的默认属性即为线程锁,进程间私有), 进程锁:PTHREAD_PROCESS_SHARED
示例程序
- 定义全局互斥量,初始化init(&m, NULL)互斥量,添加对应的destry
- 两个线程while中,两次printf前后,分别加lock和unlock
- 将unlock挪至第二个sleep后,发现交替现象很难出现。
线程在操作完共享资源后本应该立即解锁,但修改后,线程抱着锁睡眠。睡醒解锁后又立即加锁,这两个库函数本身不会阻塞。
所以在这两行代码之间失去cpu的概率很小。因此,另外一个线程很难得到加锁的机会。 - main 中加flag = 5 将flg在while中-- 这时,主线程输出5次后试图销毁锁,但子线程未将锁释放,无法完成。
- main 中加pthread_cancel()将子线程取消。
mutex_test.c
哲学家吃饭: mutex_dinner
进程间使用mutex来实现同步: mutex_process.c
spin
当线程A想要获取一把自旋锁而该锁又被其它线程锁持有时, 线程A会在一个循环中自旋以检测锁是不是已经可用了. 对于自选锁需要注意
- 由于自旋时不释放CPU, 因而持有自旋锁的线程应该尽快释放自旋锁, 否则等待该自旋锁的线程会一直在那里自旋, 这就会浪费CPU时间
- 持有自旋锁的线程在sleep之前应该释放自旋锁以便其它线程可以获得自旋锁
spin与mutex
从实现原理上来讲:
- Mutex属于sleep-waiting类型的锁. 例如在一个双核的机器上有两个线程(线程A和线程B), 它们分别运行在Core0和Core1上. 假设线程A想要通过pthread_mutex_lock操作去得到一个临界区的锁, 而此时这个锁正被线程B所持有, 那么线程A就会被阻塞(blocking), Core0 会在此时进行上下文切换(Context Switch)将线程A置于等待队列中, 此时Core0就可以运行其他的任务(例如另一个线程C)而不必进行忙等待.
- 而Spin lock则不然, 它属于busy-waiting类型的锁, 如果线程A是使用pthread_spin_lock操作去请求锁, 那么线程A就会一直在Core0上进行忙等待并不停的进行锁请求, 直到得到这个锁为止
如果去查阅Linux glibc中对pthreads API的实现NPTL(Native POSIX Thread Library) 的源码的话(使用”getconf GNU_LIBPTHREAD_VERSION”命令可以得到我们系统中NPTL的版本号), 就会发现pthread_mutex_lock()操作如果没有锁成功的话就会调用system_wait()的系统调用并将当前线程加入该mutex的等待队列里. 而spin lock则可以理解为在一个while(1)循环中用内嵌的汇编代码实现的锁操作(印象中看过一篇论文介绍说在linux内核中spin lock操作只需要两条CPU指令, 解锁操作只用一条指令就可以完
对于自旋锁来说,它只需要消耗很少的资源来建立锁;随后当线程被阻塞时,它就会一直重复检查看锁是否可用了,也就是说当自旋锁处于等待状态时它会一直消耗CPU时间。
对于互斥锁来说,与自旋锁相比它需要消耗大量的系统资源来建立锁;随后当线程被阻塞时,线程的调度状态被修改,并且线程被加入等待线程队列;最后当锁可用时,在获取锁之前,线程会被从等待队列取出并更改其调度状态;但是在线程被阻塞期间,它不消耗CPU资源
自旋锁和互斥锁适用于不同的场景。自旋锁适用于那些仅需要阻塞很短时间的场景,而互斥锁适用于那些可能会阻塞很长时间的场景。
int pthread_spin_destroy(pthread_spinlock_t *);
int pthread_spin_init(pthread_spinlock_t *, int);
int pthread_spin_lock(pthread_spinlock_t *);
int pthread_spin_trylock(pthread_spinlock_t *);
int pthread_spin_unlock(pthread_spinlock_t *);
cond
条件变量本身不是锁!但它也可以造成线程阻塞。通常与互斥锁配合使用。给多线程提供一个会合的场所。
- 互斥量: 保护一块共享数据----> 保护数据
- 条件变量: 引起阻塞, 生产者和消费者模型----> 阻塞线程
条件变量的两个动作
- 条件不满足: 阻塞线程
- 条件满足: 通知阻塞的线程开始工作
条件变量的优点: 相较于mutex而言,条件变量可以减少竞争。
如直接使用mutex,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果汇聚(链表)中没有数据,消费者之间竞争互斥锁是无意义的。有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的竞争。提高了程序效率。
int pthread_cond_init(
pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr); 第二参数一般为NULL
// 也可以使用静态初始化的方法,初始化条件变量: pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex); // 阻塞等待一个条件变量
// 1. 阻塞等待条件变量cond(参1)满足
// 2. 释放已掌握的互斥锁(解锁互斥量)相当于pthread_mutex_unlock(&mutex);
// 3. 当被唤醒,pthread_cond_wait函数返回时,解除阻塞并重新申请获取互斥锁pthread_mutex_lock(&mutex);
// 1.2.两步为一个原子操作。
// 限时等待一个条件变量
int pthread_cond_timedwait(
pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒至少一个阻塞在条件变量上的线程
int pthread_cond_broadcast(pthread_cond_t *cond); // 唤醒全部阻塞在条件变量上的线程
示例程序
生产者和消费者模型: cond_producer_customer.c
semaphore
mutex初始化为1
lock之后为0
unlock之后为1
mutex实现的同步都是串行的
进化版的互斥锁(1 --> N)
由于互斥锁的粒度比较大,如果我们希望在多个线程间对某一对象的部分数据进行共享,使用互斥锁是没有办法实现的,只能将整个数据对象锁住。这样虽然达到了多线程操作共享数据时保证数据正确性的目的,却无形中导致线程的并发性下降。线程从并行执行,变成了串行执行。与直接使用单进程无异。
信号量,是相对折中的一种处理方式,既能保证同步,数据不混乱,又能提高线程并发。
int sem_init(
sem_t *sem,
int pshared, // 取0用于线程间;取非0(一般为1)用于进程间
unsigned int value); // value指定信号量初值
int sem_destroy(sem_t *sem);
int sem_wait(sem_t *sem); // 信号量加锁 --
// 调用一次相当于对sem做了-1操作
// 如果sem值为0, 线程会阻塞
int sem_trywait(sem_t *sem); // 尝试对信号量加锁 --
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout); // 限时尝试对信号量加锁 --
int sem_post(sem_t *sem); // 给信号量解锁 ++
示例程序
使用信号量实现生产者, 消费者模型: sem_product_consumer.c
文件锁
借助 fcntl函数来实现锁机制。 操作文件的进程没有获得锁时,可以打开,但无法执行read、write操作。
依然遵循“读共享、写独占”特性。但!如若进程不加锁直接操作文件,依然可访问成功,但数据势必会出现混乱。
【思考】:多线程中,可以使用文件锁吗?
多线程间共享文件描述符,而给文件加锁,是通过修改文件描述符所指向的文件结构体中的成员变量来实现的。因此,多线程中无法使用文件锁
int fcntl(int fd, int cmd, ... /* arg */ );
F_SETLK (struct flock *) // 设置文件锁(trylock)
F_SETLKW (struct flock *) // 设置文件锁(lock)W --> wait
F_GETLK (struct flock *) // 获取文件锁
struct flock {
// ...
short l_type; // 锁的类型:F_RDLCK 、F_WRLCK 、F_UNLCK
short l_whence; // 偏移位置:SEEK_SET、SEEK_CUR、SEEK_END
off_t l_start; // 起始偏移:1000
off_t l_len; // 长度:0表示整个文件加锁
pid_t l_pid; // 持有该锁的进程ID:(F_GETLK only)
// ...
};
示例程序
多个进程对加锁文件进行访问: file_lock.c