linux服务器开发 2 系统编程

进程

进程和程序的概念

并发:在操作系统中,一个时间段中有多个进程都处于已启动运行到运行完毕之间的状态。但,任一个时刻点上仍只有一个进程在运行。

  • 单道程序设计模式:dos。cpu占用要排队

  • 多道程序设计模式:并行,时间轮片、时钟中断

CPU简易架构

1

预处理——编译——汇编——链接
a) 预处理阶段:我理解的主要是处理一些#开头的程序内容,例如#ifdef, #include,#define等,事实上这个过程之后生成的预编译文件会比较大。主要是include导致的我认为。删除所有的注释,添加行号和文件标识
b) 编译阶段:编译器在这个过程中会对代码进行检查优化,指出语法错误等各种编译错误,之后生成汇编代码
c) 汇编阶段:将上一步产生的代码逐行转换成机器码。
d) 链接阶段:将上一阶段中编译器产生的各种目标文件链接起来,将未定义标识符的引用全部替换成正确的地址。主要包含静态链接和动态链接两种情形。相比较来说动态链接生成的可执行文件小一些,但是会损耗一些性能。

MMU内存管理单元

1

  • text:代码
  • data:数据(只读数据、未初始化数据)
  • 堆区:下往上
  • 栈区:上往下,高向低,
  • 虚拟内存0-3G:用户空间,权限小,只能访问0-3g的数据
  • 虚拟内存3-4G:内核空间,PCB进程控制块,权限大,能访问所有的数据
  • 虚拟内存不真实存在,放在物理内存中。
    • 虚拟地址:可用的地址空间,4g
    • 物理地址:MMU映射到物理内存
    • MMU还能设置访问级别,0,1,2,3。linux只用了0级和3级。

1

  • 用户空间的数据都会独立映射到物理内存中
  • 不同进程的内核空间映射的物理内存是一样的,但是PCB的内容不一样

进程控制块/进程描述符

每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息

Linux内核的进程控制块是task_struct结构体。

/usr/src/linux-headers-3.16.0-30/include/linux/sched.h文件中可以查看struct task_struct 结构体定义。

重点掌握以下部分即可:

* 进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。

* 进程的状态,有就绪、运行、挂起、停止等状态。

* 进程切换时需要保存和恢复的一些CPU寄存器。

* 描述虚拟地址空间的信息。

* 描述控制终端的信息。

* 当前工作目录(Current Working Directory)。

* umask掩码。

* 文件描述符表,包含很多指向file结构体的指针。

* 和信号相关的信息。

* 用户id和组id。

* 会话(Session)和进程组。

* 进程可以使用的资源上限(Resource Limit)。

进程状态

进程基本的状态有5种。分别为初始态,就绪态,运行态,挂起态与终止态。其中初始态为进程准备阶段,常与就绪态结合来看。

1

环境变量

linux:多用户多任务的开源系统

环境变量,是指在操作系统中用来指定操作系统运行环境的一些参数。通常具备以下特征:

① 字符串(本质) ② 有统一的格式:名=值1:值2 ③ 值用来描述进程环境信息。

存储形式:与命令行参数类似。char *[]数组,数组名environ,内部存储字符串,NULL作为哨兵结尾。

使用形式:与命令行参数类似。

加载位置:与命令行参数类似。位于用户区,高于stack的起始位置。

引入环境变量表:须声明环境变量。

​ extern char ** environ;

练习:打印当前进程的所有环境变量。 【environ.c】

常用环境变量:

  • echo $PATH:可执行文件搜索的路径
  • echo $SHELL:当前使用的命令解析器Shell,通常是/bin/bash。
  • echo $LANG:语言和locale
  • echo $HOME:家目录。保存每个用户在运行该程序时自己的一套配置。
  • echo $TREM:当前终端类型,在图形界面终端下它的值通常是xterm,终端类型决定了一些程序的输出显示方式,比如图形界面终端可以显示汉字,而字符终端一般不行。

环境变量函数

getenv:获取环境变量。
char *getenv(const char *name); 	成功:返回环境变量的值;失败:NULL (name不存在)

setenv:改变或增加环境变量
int setenv(const char *name, const char *value, int overwrite);  	成功:0;失败:-1
overwrite:1:覆盖原环境变量, 0:不覆盖

unsetenv:删除环境变量
int unsetenv(const char *name); 	成功:0;失败:-1 
name不存在仍返回0(成功),仅当name命名错误时出错。如:"ABC="

进程控制

创建子进程:

#include <unistd.h>
pid_t fork(void) # 失败返回-1;成功: 父进程返回子进程的ID(非负) 子进程返回 0 。
    # pid_t类型表示进程ID,但为了表示-1,它是有符号整型。

pid_t getpid(void)  # 返回当前进程pid
pid_t getppid(void)  # 返回父进程pid
    
区分一个函数是“系统函数”还是“库函数”依据:
 	1、是否访问内核数据结构
	2、是否访问外部硬件资源		
    二者有任一 	→ 	系统函数;
    二者均无 	→ 	库函数

创建多个子进程:

for(int i=0; i<5, i++){
	pid = fork();
	if(pid==0){
		break;
	}
}
if(i<5){
	sleep(i);
	printf("child %d, pid=%u\n", i+1, get(pid));
} else{
	sleep(i);
	printf("parent\n");
}

# 不加sleep时,shell的进程可能提前抢占输出界面,导致输出乱序。

父子进程共享

# 比如sudo命令,实际用户id不变,有效用户id变成root
uid_t getuid(void)  # 获取当前进程实际用户id
uid_t geteuid(void)  # 获取当前进程有效用户id
    
gid_t getgid(void); # 获取当前进程使用用户组ID
gid_t getegid(void); # 获取当前进程有效用户组ID

进程共享

刚fork之后:

父子相同处: 全局变量、.data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式…

父子不同处: 1.进程ID 2.fork返回值 3.父进程ID 4.进程运行时间 5.闹钟(定时器) 6.未决信号集

  • 子进程复制了父进程0-3G用户空间内容,以及父进程的PCB,但pid不同。

【重点】:

父子进程间遵循读时共享、写时复制的原则。这样设计,无论子进程执行父进程的逻辑还是执行自己的逻辑都能节省内存开销。

  • 0-3G用户空间内容是共享的还是独享的?

    • 在读操作时是共享的,写操作是独享的
    • 写操作,不会改变虚拟地址,物理地址改变了?
  • 父子进程共享:1. 文件描述符(打开文件的结构体) 2. mmap建立的映射区 (进程间通信详解)

  • fork之后父进程先执行还是子进程先执行不确定。取决于内核所使用的调度算法。

gdb调试

使用gdb调试的时候,gdb只能跟踪一个进程。可以在fork函数调用之前,通过指令设置gdb调试工具跟踪父进程或者是跟踪子进程。默认跟踪父进程。

set follow-fork-mode child 命令设置gdb在fork之后跟踪子进程。

set follow-fork-mode parent 设置跟踪父进程。

注意,一定要在fork函数调用之前设置才有效。 【follow_fork.c】

gcc demo.c -g #编译时必须加-g参数,得到a.out
gdb a.out
list  # 把列表列出,设置断点?
run # 运行
start #单独执行

exec函数族

引入:fork创建子进程后执行的是和父进程相同的程序,但有可能执行不同的代码分支。子进程想要执行其他程序——用exec函数。

  • 当进程调用exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。
  • 调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。

将当前进程的.text、.data替换为所要加载的程序的.text、.data,然后让进程从新的.text第一条指令开始执行,但进程ID不变,换核不换壳。

l (list)			文件列表
p (path)			搜素file时使用path变量
e (environment)	    使用环境变量数组,不使用进程原有的环境变量,设置新加载程序运行的环境变量
v (vector)			使用命令行参数数组
    # 只有失败返回值-1

int execl(const char *path, const char *arg, ...);  
# 加载一个进程, 通过 路径+程序名 来加载
# 参数1:路径+程序名
# eg:execlp("/bin/ls", "ls", "-l" "-a", NULL);

int execlp(const char *file, const char *arg, ...);
# 加载一个进程,借助PATH环境变量。成功:无返回;失败:-1
# 参数1:要加载的程序的名字,需要配合PATH环境变量来使用,当PATH中所有目录搜索后没有参数1则出错返回。
# 该函数通常用来调用系统程序。如:ls、date、cp、cat等命令。
# eg:execlp("ls", "ls", "-l" "-a", NULL);

int execle(const char *path, const char *arg, ..., char *const envp[]);
# 借助自定义环境变量env

变参形式: ①... ② argv[]  (main函数也是变参函数,形式上等同于 int main(int argc, char *argv0, ...)) 
变参终止条件:① NULL结尾 ② 固参指定

int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
#eg: 
char *argv[]= {"ls", "-l" "-a", NULL};
execv("/bin/ls", argv);

  • 失败返回-1,成功不返回。通常我们直接在exec函数调用后直接调用perror()和exit(),无需if判断。
  • 只有execve是真正的系统调用,其它五个函数最终都调用execve

1

回收子进程

孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为init进程,称为init进程领养孤儿进程。

僵尸进程:进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。

特别注意,僵尸进程是不能使用kill命令清除掉的。

原因:一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程。

当进程终止时,操作系统的隐式回收机制会:1.关闭所有文件描述符 2. 释放用户空间分配的内存。内核的PCB仍存在。其中保存该进程的退出状态。(正常终止→退出值;异常终止→终止信号)

linux中异常退出都是信号导致的,信号编号通过kill -l查看。

回收僵尸进程:
    1、kill父进程,父进程变成孤儿进程后,被init领养,会清理僵尸进程
    2、wait、waitpid
    
功能:
	阻塞等待子进程退出。 # 父进程不做其他事
	回收子进程残留资源 
	获取子进程结束状态(退出原因)。
pid_t wait(int *status); 
返回值:
    成功:子进程ID;
    失败:-1 (没有子进程)  
参数:status是传出参数,保存进程的退出状态。用宏函数查看。
    1.  WIFEXITED(status) 为非0	→ 进程正常结束 Wait IF EXITED
    WEXITSTATUS(status) 如上宏为真,使用此宏 → 获取进程退出状态 (exit的参数) W EXIT STATUS
    2.  WIFSIGNALED(status) 为非0 → 进程异常终止 Wait IF SIGNALED
    WTERMSIG(status) 如上宏为真,使用此宏 → 取得使进程终止的那个信号的编号。 W TERM SIG
    *3.  WIFSTOPPED(status) 为非0 → 进程处于暂停状态  W IF STOPPED
    WSTOPSIG(status) 如上宏为真,使用此宏 → 取得使进程暂停的那个信号的编号。 W STOP SIG
    WIFCONTINUED(status) 为真 → 进程暂停后已经继续运行 W IF CONTINUED
    
# 指定进程pid进行清理,不会阻塞父进程
pid_t waitpid(pid_t pid, int *status, int options);	
返回值:
    成功:子进程ID;
    失败:返回-1(无子进程)。
    options为WNOHANG,且子进程正在运行,返回0。  
参数:
    options:
    	0			阻塞,wait。
    	WNOHANG		非阻塞,轮询。  ”Wait no hang“
    pid: 
    	大于0,	回收指定ID子进程;
    	-1,		 回收所有子进程,相当于wait; 
    	0,	 	 回收与父进程同进程组的子进程;  
    	-pgid,	 回收指定进程组pgid的所有子进程

注意:wait一次执行,只会回收一个子进程,当有多个子进程时,需要循环执行wait。

进程间通信IPC

Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。

任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问。

交换进程间数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)。

1

方法:文件、管道、信号、共享内存、消息队列、套接字、命名管道等。一些方法由于自身设计缺陷被淘汰或者弃用。

常用方法:

  • 管道 (使用最简单,语法简单)

  • 信号 (开销最小)

  • 共享映射区 (无血缘关系)

  • 本地套接字 (最稳定,实现复杂)

管道

特质:

  1. 其本质是一个伪文件(实为内核缓冲区)
  2. 由两个文件描述符引用,一个表示读端,一个表示写端。
  3. 规定数据从管道的写端流入管道,从读端流出。

原理: 内核使用环形队列机制,借助内核缓冲区(4k)实现。通过 ulimit -a查看,pipe size

  • 内核缓冲区:
  • 环形队列:

局限性:队列机制,FIFO,单向

  1. 数据自己读不能自己写。
  2. 数据一旦被读走,便不在管道中存在,不可反复读取。
  3. 由于管道采用半双工通信方式。只能单向通信。
  4. 只允许具有血缘关系的进程间通信。有公共祖先
int pipe(int pipefd[2]);		
返回值
    成功:0;
    失败:-1,设置errno
参数:
    pipefd:文件描述符,规定:fd[0] → r; fd[1] → w
    无需open,但需手动close。 eg: close(fd[0]),关闭读端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void){
	int fd[2];
	pid_t pid;
	int ret = pipe(fd);
	if(ret==-1){
		perror("pipe error;");
		exit(1);
	}
	pid = fork();
	if(pid==-1){
		perror("fork error;");
		exit(1);
	} else if(pid==0){  # 子进程,读数据
		close(fd[1]);
		char buf[1024];
		read(fd[0], buf, sizeof(buf));
		if(ret==0){  # 文件末尾读到0
			printf("---\n");
		}
		write(STDOUT_FILENO, buf, ret);  
	}else{		# 父进程,写数据
		close(fd[0]);
		write(fd[1], "hello pipe\n", sizeof("hello pipe\n"));
	}
}
  1. 父进程调用pipe函数创建管道,得到两个文件描述符fd[0]、fd[1]指向管道的读端和写端。

  2. 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道。

  3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以向管道中写入数据,子进程将管道中的数据读出。由于管道是利用环形队列实现的,数据从写端流入管道,从读端流出,这样就实现了进程间通信。

1

管道引用计数

  • 所有指向管道写端的文件描述符都关闭了(管道写端引用计数为0)
  • 所有指向管道读端的文件描述符都关闭了(管道读端引用计数为0)

管道读取数据的四种的情况

  1. 如果一个管道的写端一直在写,而读端的引⽤计数是否⼤于0决定管道是否会堵塞,引用计数大于0,只写不读再次调用write会导致管道堵塞;
  2. 如果一个管道的读端一直在读,而写端的引⽤计数是否⼤于0决定管道是否会堵塞,引用计数大于0,只读不写再次调用read会导致管道堵塞;
  3. 当他们的引用计数等于0时
    • 只写不读会导致写端的进程收到一个SIGPIPE信号,导致进程终止。
    • 只读不写会导致读端返回0,就像读到文件末尾一样。
管道缓冲区大小
1、 ulimit –a
    pipe size            (512 bytes, -p) 8
2、 long fpathconf(int fd, int name);	# 头文件 <unistd.h>
	成功:返回管道的大小	失败:-1,设置errno
    
popen和pclose函数   # <stdio.h>
    popen:创建一个管道,并fork一个子进程,该子进程根据popen传入的参数,关闭管道的对应端,然后执行传入的shell命令,然后等待终止。
	FILE *popen(const char *command, const char *type);   //成功返回标准文件I/O指针,失败返回NULL
		参数:command:shell命令行     type:“r/w” 表示标准输出和输入
    pclose:关闭由popen创建的标准I/O流,等待其中的命令终止,然后返回shell的执行状态。
    int pclose(FILE *stream);   //成功返回shell的终止状态,失败返回-1

FIFO:

命名管道,以区分管道(pipe)。

  • pipe:只能用于“有血缘关系”的进程间。
  • FIFO:不相关的进程也能交换数据。

每个FIFO都有一个路径名与之相关联,允许无亲缘关系的任意两个进程间通过FIFO进行通信。

FIFO是Linux基础文件类型中的一种。但,FIFO文件在磁盘上没有数据块,仅仅用来标识内核中一条通道。

各进程可以打开这个文件进行read/write,实际上是在读写内核通道,这样就实现了进程间通信。

1. 命令:mkfifo 管道名
2. 库函数:int mkfifo(const char *pathname,  mode_t mode);  
	返回值:  成功:0; 失败:-1,FIFO已经存在,errno置为EEXIST
    参数: 
    	pathname:Linux路径名, 
    	mode权限,8进制:S_IRUSR,S_IWUSR,S_IRGRP,S_IWGRP,S_IROTH,S_IWOTH。
        
当创建一个FIFO后,它必须以只读方式打开或者只写方式打开,所以可以用open函数,当然也可以使用标准的文件I/O打开函数,例如fopen来打开。由于FIFO是半双工的,所以不能够同时打开来读和写。
        一般的文件I/O函数,如read,write,close,unlink都可用于FIFO。
        对管道和FIFO使用lseek函数,是错误的,会返回ESPIPE错误。
    可多读端、多写端
    同一目录下的同一文件,

共享存储映射

fork后父子进程共享文件描述符,无血缘关系进程可以打开同一文件

1、存储映射I/O (Memory-mapped I/O)

使一个磁盘文件与存储空间中的一个缓冲区相映射。

  • 当从缓冲区中取数据,就相当于读文件中的相应字节。
  • 将数据存入缓冲区,则相应的字节就自动写入文件。
  • 这样,就可在不适用read和write函数的情况下,使用地址(指针)完成I/O操作。

mmap函数:通知内核,将一个指定文件映射到存储区域中。用于IPC

1

mmap函数   # <sys/mman.h>
void *mmap(void *adrr, size_t length, int prot, int flags, int fd, off_t offset); 
返回:成功:返回创建的映射区首地址;失败:MAP_FAILED宏
参数:	
	addr: 	 建立映射区的首地址,由Linux内核指定。使用时,直接传递NULL
	length: 创建映射区的大小
	prot:	映射区权限 PROT_READ、PROT_WRITE、PROT_READ|PROT_WRITE
	flags:	标志位参数(常用于设定更新物理区域、设置共享、创建匿名映射区)
		  	 MAP_SHARED:  会将映射区所做的操作反映到物理设备(磁盘)上。
		  	 MAP_PRIVATE: 映射区所做的修改不会反映到物理设备。
	fd: 	用来建立映射区的文件描述符
	offset: 映射文件的偏移(4k的整数倍)

munmap函数:释放映射区
int munmap(void *addr, size_t length);	
返回:成功:0; 失败:-1

1、可以malooc一个0字节的对空间,然后free。但是不可以创建0字节的mmap,新create出来的文件不能创建mmap。
2、如果映射区地址变化了,munmap会出错。
3、映射区的权限要小于等于打开文件的权限,映射区创建过程隐含对文件的读操作。——文件的读权限一定要有。
4、offset必须是4k的整数倍,一页的大小。
5、必须检查mmap的返回值
6、文件描述符先关闭,对mmap映射没有影响。fd只是一个句柄。    
                 
获取文件大小:
	off_t lseek(int fd, off_t offset, int whence);
	参数:
        fd:文件描述符
        offset:偏移量
        whence:起始偏移的位置。SEEK_SET开头、SEEK_CUR当前、SEEK_END结尾
    truncate(const char *path, off_t length);
	参数:path:文件名,  length:大小

文件:
    inode:文件属性信息,包含struct stat结构体,文件存储指针地址(指向数据块首地址)、大小、权限、类型、所有者。
    denty:目录项=文件名+inode编号。  创建硬连接就是创建denty。查看硬连接是,inode信息都是一样的。大小、权限等。
unlink("文件名");  # 删除文件目录项denty。使具备被删除的条件。当占用文件的所有进程使用结束/关闭时释放。
        

2、匿名映射

通常为了建立映射区要open一个temp文件,创建好了再unlink、close掉,比较麻烦。

匿名映射区的方法,无需依赖一个文件即可创建映射区。同样需要借助标志位参数flags来指定

MAP_ANONYMOUS (或MAP_ANON)
int *p = mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0); 
不同: length可以任意,文件描述符fd=-1
    
注意,MAP_ANONYMOUS和MAP_ANON两个宏是Linux操作系统特有的。在类Unix系统中如无该宏定义,可使用如下两步来完成匿名映射区的建立。
	fd = open("/dev/zero", O_RDWR);
	p = mmap(NULL, size, PROT_READ|PROT_WRITE, MMAP_SHARED, fd, 0);

3、mmap无血缘关系进程间通信

实质上mmap是内核借助文件帮我们创建了一个映射区,多个进程之间利用该映射区完成数据传递。

由于内核空间多进程共享,因此无血缘关系的进程间也可以使用mmap来完成通信。

只要设置相应的标志位参数flags,使用MAP_SHARED。

strace 文件路径
追踪文件执行过程中的系统调用。read/write底层也用mmap实现。

信号

信号的特质:由于信号是通过软件方法实现,其实现手段导致信号有很强的延时性。但对于用户来说,这个延迟时间非常短,不易察觉。

每个进程收到的所有信号,都是由内核负责发送的,内核处理。

信号四要素:编号、名称、事件、默认处理动作

信号编号:kill -l 显示信号,man 7 signal 查看帮助文档。不存在编号为0的信号。其中1-31号信号称之为常规信号(也叫普通信号或标准信号),34-64称之为实时信号,驱动编程与硬件相关。名字上区别不大。而前32个名字各不相同。

递达:递送并且到达进程。 阻塞态:集合:阻塞信号集(信号屏蔽字)、未决信号集

未决:产生和递达之间的状态。主要由于阻塞(屏蔽)导致该状态。

信号的处理方式:

  1. 执行默认动作

  2. 忽略(丢弃) ,不处理

  3. 捕捉(调用户处理函数),做其他处理

Linux内核的进程控制块PCB是一个结构体,task_struct, 除了包含进程id,状态,工作目录,用户id,组id,文件描述符表,还包含了信号相关的信息,主要指阻塞信号集和未决信号集。

默认动作

​ Term:终止进程

​ Ign: 忽略信号 (默认即时对该种信号忽略操作)

​ Core:终止进程,生成Core文件。(查验进程死亡原因, 用于gdb调试)

​ Stop:停止(暂停)进程

​ Cont:继续运行进程

    1. SIGKILL 和19) SIGSTOP信号,不允许忽略和捕捉,只能执行默认动作。甚至不能将其设置为阻塞。

信号产生:

  1. 终端按键产生,如:Ctrl+c、Ctrl+z、Ctrl+\

  2. 系统调用产生,如:kill、raise、abort

  3. 软件条件产生,如:定时器alarm、setitimer

  4. 硬件异常产生,如:非法访问内存(段错误)、除0(浮点数例外)、内存对齐出错(总线错误)

  5. 命令产生, 如:kill命令

阻塞信号集(信号屏蔽字): 将某些信号加入集合,对他们设置屏蔽,当屏蔽x信号后,再收到该信号,该信号的处理将推后(解除屏蔽后)

未决信号集:

  1. 信号产生,未决信号集中描述该信号的位立刻翻转为1,表信号处于未决状态。当信号被处理对应位翻转回为0。这一时刻往往非常短暂。

  2. 信号产生后由于某些原因(主要是阻塞)不能抵达。这类信号的集合称之为未决信号集。在屏蔽解除前,信号一直处于未决状态。

终端按键产生信号

Ctrl + c → 2) SIGINT(终止/中断) “INT” ----Interrupt

Ctrl + z → 20) SIGTSTP(暂停/停止) “T” ----Terminal 终端。

Ctrl + \ → 3) SIGQUIT(退出)

硬件异常产生信号

除0操作 → 8) SIGFPE (浮点数例外) “F” -----float 浮点数。

非法访问内存 → 11) SIGSEGV (段错误)

总线错误 → 7) SIGBUS

系统调用产生信号

kill函数/命令产生信号    
kill命令产生信号:kill -SIGKILL pid
kill函数:给指定进程发送指定信号,不是发杀死信号。
int kill(pid_t pid, int sig);	 
返回: 成功:0;失败:-1 (ID非法,信号非法,普通用户杀init进程等权级问题),设置errno
	sig:不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。
    pid > 0:  发送信号给指定的进程。
	pid = 0:  发送信号给 与调用kill函数进程属于同一进程组的所有进程。
	pid < 0:  取|pid|发给对应进程组。
	pid = -1:发送给进程有权限发送的系统中所有进程。

raise和abort函数
raise 函数:给当前进程发送指定信号(自己给自己发) # raise(signo) == kill(getpid(), signo);
int raise(int sig);    成功:0,失败非0值
    
abort 函数:给自己发送异常终止信号 6) SIGABRT 信号,终止并产生core文件
void abort(void);      该函数无返回

进程组:每个进程都属于一个进程组,进程组是一个或多个进程集合,他们相互关联,共同完成一个实体任务,每个进程组都有一个进程组长,默认进程组ID与进程组长ID相同。

权限保护:super用户(root)可以发送信号给任意用户,普通用户是不能向系统用户发送信号的。 kill -9 (root用户的pid) 是不可以的。同样,普通用户也不能向其他普通用户发送信号,终止其进程。 只能向自己创建的进程发送信号。普通用户基本规则是:发送者实际或有效用户ID == 接收者实际或有效用户ID

软件条件产生信号

alarm函数	# 经过指定时间(秒级)后,发送 14)SIGALRM信号,默认动作终止。
unsigned int alarm(unsigned int seconds); 
返回: 0 或 剩余的秒数,无失败。
	例:alarm(5) → 3sec → alarm(4) → 5sec → alarm(5) → alarm(0)取消定时器
注意:
    1、每个进程都有且只有唯一个定时器。
	2、定时,与进程状态无关(自然定时法),无论进程处于何种状态,alarm都计时。就绪、运行、挂起(阻塞、暂停)、终止、僵尸...
    3、实际执行时间 = 系统时间 + 用户时间 + 等待时间
    
setitimer函数  # 精度微秒us,可以实现周期定时。
int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);	
返回: 成功:0; 失败:-1,设置errno
参数:
    which:定时方式
    ① 自然定时:ITIMER_REAL → 14)SIGLARM				 		计算自然时间
    ② 虚拟空间计时(用户空间):ITIMER_VIRTUAL → 26)SIGVTALRM  	 只计算进程占用cpu的时间
    ③ 运行时计时(用户+内核):ITIMER_PROF → 27)SIGPROF		 计算占用cpu及执行系统调用的时间
    itimerval:  # 第一次出发时长 it_value,之后触发时长 it_interval
    		struct itimerval {
               struct timeval it_interval; /* 周期计时器的间隔  */
               struct timeval it_value;    /* 定时的时长 */
            };

           struct timeval {
               time_t      tv_sec;         /* seconds */
               suseconds_t tv_usec;        /* microseconds */
            };

信号集:阻塞、未决

内核通过读取未决信号集来判断信号是否应被处理。

信号屏蔽字mask可以影响未决信号集。、

知识点:

  1. 信号集 sigset_t set
  2. 信号集处理函数:sigemptyset、sigfillset、sigaddset、sigdelset、sigismember
  3. 修改阻塞信号集:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  4. 读取未决信号集:int sigpending(sigset_t *set);
信号集
sigset_t  set;		// typedef unsigned long sigset_t;   8字节
sigset_t类型的本质是位图,但不应该直接使用位操作。应该使用信号集操作函数,保证跨系统操作有效。

信号集操作函数
int sigemptyset(sigset_t *set);	将某个信号集清0		成功:0;失败:-1
int sigfillset(sigset_t *set);	将某个信号集置1 		成功:0;失败:-1
int sigaddset(sigset_t *set, int signum); 将某个信号加入信号集  	成功:0;失败:-1
int sigdelset(sigset_t *set, int signum); 将某个信号清出信号集   	成功:0;失败:-1

int sigismember(const sigset_t *set, int signum);  判断某个信号是否在信号集中	
    返回值:在集合:1;不在:0;出错:-1  

sigprocmask函数 : 屏蔽信号、解除屏蔽  ——> 读取或修改进程的信号屏蔽字(PCB中)
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);	
返回: 成功:0;失败:-1,设置errno
参数:
    set:   传入参数,是一个位图,set中哪位置1,就表示当前进程屏蔽哪个信号。
    oldset:传出参数,保存旧的信号屏蔽集。
    how参数取值:	假设当前的信号屏蔽字为mask
		SIG_BLOCK:   set表示需要屏蔽的信号。相当于 mask = mask|set
 		SIG_UNBLOCK: set表示需要解除屏蔽的信号。相当于 mask = mask & ~set
 		SIG_SETMASK: set表示用于替代原始屏蔽及的新屏蔽集。相当于 mask = set
若调用sigprocmask解除了对当前若干个信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

sigpending函数 : 读 未决信号集
int sigpending(sigset_t *set);	set传出参数。   
返回值:成功:0;失败:-1,设置errno

信号捕捉

知识点:

  1. 注册捕捉函数:typedef void (*sighandler_t)(int);
  2. 信号捕捉函数:signal、sigaction
    • sighandler_t signal(int signum, sighandler_t handler);
    • int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  3. 信号捕捉结构体:sigaction(sa_handler, sa_sigaction, sa_mask, sa_flags, sa_restorer)
  4. 信号捕捉流程,do_signal 和 sigreturn 。
1. signal函数 : 注册一个信号捕捉函数 
#include <signal.h>
typedef void (*sighandler_t)(int);
# 捕捉signum信号,执行handler函数
sighandler_t signal(int signum, sighandler_t handler);
返回: 成功:sighandler_t  失败:宏 SIG_ERR

2. sigaction函数  : 修改信号处理动作
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);  
返回: 成功:0;失败:-1,设置errno
参数:
	act:传入参数,新的处理方式。
    oldact:传出参数,旧的处理方式。
	sigaction结构体
      	struct sigaction {
            void     (*sa_handler)(int);   # 注册函数名
            void     (*sa_sigaction)(int, siginfo_t *, void *);  
            	# 当sa_flags被指定为SA_SIGINFO标志时,使用该信号处理程序。一般不用。
            sigset_t   sa_mask;   # 信号处理期间,屏蔽的信号。非屏蔽信号到来会跳出处理函数。
            int       sa_flags;   # 通常设置为0,信号处理期间屏蔽本信号。
            void     (*sa_restorer)(void);   # 废弃
   	 	};
注意:
    信号处理期间,sa_mask会屏蔽PCB的信号屏蔽字,处理完后再恢复。
    信号处理过程中,同一信号会被屏蔽。
    常规信号不支持排队,多次信号递达只会记录一次。(后32个实时信号支持排队)

内核实现信号捕捉过程:

  • 程序工作在用户空间,因异常、中断、系统调用进入内核。
  • 内核处理异常,后处理可递达的信号。若信号是捕捉的,调用do_signal函数。
  • do_signal是回调函数,在内核调用。回到用户空间调用函数体。
  • 执行完函数体后,会用sigreturn函数回到内核。

1

竞态条件(时序竞态)

信号不可靠,系统负载越严重,信号不可靠性越强。:信号是通过软件方式实现(跟内核调度高度依赖,延时性强),每次系统调用结束后,或中断处理处理结束后,需通过扫描PCB中的未决信号集,来判断是否应处理某个信号。当系统负载过重时,会出现时序混乱。

时序竞态产生的情况:

    1. 欲睡觉,定闹钟10分钟,希望10分钟后闹铃将自己唤醒。     
    2. 正常:定时,睡觉,10分钟后被闹钟唤醒。
    3. 异常:闹钟定好后,被唤走,外出劳动,20分钟后劳动结束。回来继续睡觉计划,但劳动期间闹钟已经响过,不会再将我唤醒。
pause函数  :  进程主动挂起,等待信号唤醒。进程将处于阻塞状态(主动放弃cpu) 。
int pause(void);	
返回值:-1 并设置errno为EINTR。
    当信号为捕捉,处理完毕后返回-1。errno设置为EINTR,表示“被信号中断”。
    当信号处理动作为忽略,进程继续挂起,不返回。
    当信号被屏蔽,pause不能被唤醒。

sigsuspend函数  :  sigsuspend函数调用期间,进程信号屏蔽字由其参数mask指定。
int sigsuspend(const sigset_t *mask);	挂起等待信号。
通过设置屏蔽SIGALRM的方法,解决时序竞态问题。
过程:  
    1、为SIGALRM设置捕捉函数,什么都不做。 sigaction
    2、PCB屏蔽SIGALRM,并保存oldmask。 sigprocmask
    3、调用alarm。 alarm(n_sec)
    4、suspmask复制oldmask并解除SIGALRM屏蔽。 sigdelset
    5、用suspmask调用sigsuspend。  sigsuspend(&suspmask)
    6、恢复定时器alarm(0),恢复SIGALRM默认操作sigaction, 恢复PCB屏蔽oldmask

全局变量异步I/O

父子进程交替数数程序。当捕捉函数里面的sleep取消,程序即会出现问题。

原因:问题出现的位置,在父子进程kill函数之后需要紧接着调用 flag,将其置0,标记信号已经发送。但,在这期间很有可能被kernel调度,失去执行权利,而对方获取了执行时间,通过发送信号回调捕捉函数,从而修改了全局的flag。

解决方法:

  • 不用或用局部变量代替全局变量。
  • “锁”机制。

可/不可重入函数

一个函数在被调用执行期间(尚未调用结束),由于某种时序又被重复调用,称之为“重入”。根据函数实现的方法可分为“可重入函数”和“不可重入函数”两种。看如下时序。

1

显然,insert函数是不可重入函数,重入调用,会导致意外结果呈现。究其原因,是该函数内部实现使用了全局变量。

注意:

  1. 定义可重入函数,函数内不能含有全局变量及static变量,不能使用malloc、free

  2. 信号捕捉函数应设计为可重入函数

  3. 信号处理程序可以调用的可重入函数可参阅man 7 signal

  4. 没有包含在上述列表中的函数大多是不可重入的,其原因为:

    a. 使用静态数据结构

    b. 调用了malloc或free

    c. 是标准I/O函数

SIGCHLD信号

SIGCHLD的产生条件:

  • 子进程终止时
  • 子进程接收到SIGSTOP信号停止时
  • wa子进程处在停止态,接受到SIGCONT后唤醒时

借助SIGCHLD信号回收子进程:

  • 子进程结束运行,其父进程会收到SIGCHLD信号。该信号的默认处理动作是忽略。可以捕捉该信号,在捕捉函数中完成子进程状态的回收。
  • 信号不支持排队,当正在执行SIGCHLD捕捉函数时,应该使用循环的方法回收所有死亡的子进程。
  • 应该在fork之前应该对SIGCHLD信号进行阻塞,注册完捕捉函数后再接触阻塞,以防在注册函数过程中有子进程死亡。
  • 子进程继承了父进程的信号屏蔽字和信号处理动作,但子进程没有继承未决信号集spending。

信号传参

sigqueue函数对应kill函数,但可在向指定进程发送信号的同时携带参数
int sigqueue(pid_t pid, int sig, const union sigval value);
返回值:  成功:0;失败:-1,设置errno
参数:
    union sigval {
        int   sival_int;  # 传整数
        void *sival_ptr;  # 传地址,对于IPC无效,不同进程的虚拟地址空间是独立的。
	};


捕捉函数传参,少用。
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;
        void     (*sa_restorer)(void);
    };
sa_flags必须指定为SA_SIGINFO,不使用sa_handler而使用sa_sigaction。
siginfo_t是一个成员十分丰富的结构体类型,可以携带各种与信号相关的数据。

中断系统调用

系统调用可分为两类:慢速系统调用和其他系统调用。

  1. 慢速系统调用:可能会使进程永远阻塞的一类。如果在阻塞期间收到一个信号,该系统调用就被中断,不再继续执行(早期);也可以设定系统调用是否重启。如,read、write、pause、wait…

  2. 其他系统调用:getpid、getppid、fork…

慢速系统调用,实际上就是pause的行为。

中断慢速系统调用条件:

  1. 信号不能被屏蔽。
  2. 信号的处理方式必须是捕捉 (默认、忽略都不可以)
  3. 中断后返回-1, 设置errno为EINTR(表“被信号中断”)

sigaction的sa_flags参数:来设置被信号中断后系统调用是否重启。

  • SA_INTERRURT:不重启被信号中断后系统调用。

  • SA_RESTART:重启被信号中断后系统调用。

  • 0:阻塞同一信号。

  • SA_NODEFER:捕捉到信号后,在执行捕捉函数期间,不自动阻塞该信号,除非sa_mask中包含该信号。

终端

输入输出设备总称。

Linux系统启动步骤:init --> fork --> exec --> getty --> 用户输入帐号 --> login --> 输入密码 --> exec --> bash

硬件驱动程序负责读写实际的硬件设备。对于某些特殊字符需要经过 line disciline 线路规程过滤。比如组合键Ctrl-z等。解释成SIGTSTP信号发给前台进程。

1

进程组

定义:一个或多个进程的集合。

  • 当父进程,创建子进程的时候,默认子进程与父进程属于同一进程组。进程组ID==第一个进程ID(组长进程)。
  • 只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关。
  • 一个进程可以为自己或子进程设置进程组ID

kill -SIGKILL -进程组ID(负的)来将整个进程组内的进程全部杀死。

进程组操作函数

获取当前进程的进程组ID
pid_t getpgrp(void);  # 返回调用者的进程组ID
    
获取指定进程的进程组ID
pid_t getpgid(pid_t pid);	
返回: 成功:0;失败:-1,设置errno  
注意:如果pid = 0,那么该函数作用和getpgrp一样。
    
改变进程默认所属的进程组。将参1对应的进程,加入参2对应的进程组中。
int setpgid(pid_t pid, pid_t pgid); 	
返回: 成功:0;失败:-1,设置errno
注意:
    1. 可以让子进程自己成立一个组。setpgid(pid, pid);
    2. 如改变子进程为新的组,应fork后,exec前。 
	3. 权级问题。非root进程只能改变自己创建的子进程,或有权限操作的进程

会话

定义:一个或多个进程组的集合。

创建会话注意事项:

  1. 该调用进程是组长进程,则出错返回
  2. 调用进程变成新会话首进程(session header)
  3. 该进程成为一个新进程组的组长进程
  4. 需有root权限(ubuntu不需要)
  5. 新会话丢弃原有的控制终端,该会话没有控制终端。(仅在后台执行)
  6. 建立新会话时,先调用fork, 父进程终止,子进程调用setsid
获取进程所属的会话ID
pid_t getsid(pid_t pid); 成功:返回调用进程的会话ID;失败:-1,设置errno
注意:
    pid为0表示察看当前进程session ID
    ps ajx命令查看系统中的进程。
    	a表示不仅列当前用户的进程,也列出所有其他用户的进程。
    	x表示不仅列有控制终端的进程,也列出所有无控制终端的进程
    	j表示列出与作业控制相关的信息。
    
    
创建一个会话。调用setsid函数的进程,既是新的会长,也是新的组长。
pid_t setsid(void);  成功:返回调用进程的会话ID;失败:-1,设置errno

守护进程

Daemon(精灵)进程:Linux中的后台服务进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以d结尾的名字。比如:vsftpd、httpd、sshd、xinetd

Linux后台的一些系统服务进程,一直在运行着。

  • 没有控制终端
  • 不能直接和用户交互
  • 不受用户登录、注销的影响。
  • 操作系统关闭会关掉守护进程。可以通过设定bash解析器的配置文件 .bashrc ,在其中加入守护进程的启动。

创建守护进程

创建守护进程,最关键的一步是调用setsid函数创建一个新的Session,并成为Session Leader。

  1. 创建子进程,父进程退出:

    ​ pid_t fork()函数

    ​ 所有工作在子进程中进行形式上脱离了控制终端\

  2. 在子进程中创建新会话:

    ​ pid_t setsid()函数

    ​ 使子进程完全独立出来,脱离控制

  3. 改变当前目录为根目录 :

    ​ int chdir(dir)函数

    ​ 防止占用可卸载的文件系统(U盘、移动硬盘)

    ​ 也可以换成其它路径

  4. 重设文件权限掩码:

    ​ mode_t umask(mode_t mask)函数 一般权限是644,掩码是0002

    ​ 防止继承的文件创建屏蔽字拒绝某些权限

    ​ 增加守护进程灵活性

  5. 关闭文件描述符:

    ​ 将0、1、2重定向到 /dev/null。dup2()函数

    close(STDIN_FILENO);
    open("/dev/null", O_RDWR);
    dup2(0, STDOUT_FILENO);
    dup2(0, STDERR_FILENO);
    

    ​ 继承的打开文件不会用到,浪费系统资源,无法卸载

  6. 开始执行守护进程核心工作

  7. 守护进程退出处理程序模型

线程

LWP:light weight process 轻量级的进程,本质仍是进程(在Linux环境下)

进程:独立地址空间,拥有PCB

线程:也有PCB,但没有独立的地址空间(共享)

区别:在于是否共享地址空间。 独居(进程);合租(线程)。

Linux下:

  • 线程:最小的执行单位。(CPU分配时间轮片)

  • 进程:最小分配资源单位,可看成是只有一个线程的进程。(0-4g虚拟地址)

Linux内核线程实现原理

  1. 轻量级进程(light-weight process),也有PCB,创建线程使用的底层函数和进程一样,都是clone

  2. 从内核里看进程和线程有各自不同的PCB,但是PCB中指向内存资源的三级页表是相同的。

  • 三级页表:PCB——页目录,目录项指针——页表,指针——物理页面,内存单元——MMU——物理地址
  1. 进程可以蜕变成线程

  2. 线程可看做寄存器和栈的集合

    • 函数分配在用户空间的栈空间,栈的栈基指针edp和栈顶指针esp,刚开始重合,中间是栈帧,存放局部变量和临时值。
    • 内核空间的栈用来保存寄存器的值,比如进程(线程)切换时。
  3. 在linux下,线程最是小的执行单位;进程是最小的分配资源单位

线程号 LWP:CPU分配时间轮片的依据。ps –Lf pid 查看指定线程的lwp。

线程id:线程ID是进程内部,区分线程的识别标志。(两个进程间,线程ID允许相同)

进程和线程:

  • 如果复制对方的地址空间,那么就产出一个“进程”;如果共享对方的地址空间,就产生一个“线程”。
  • Linux内核是不区分进程和线程的。只在用户层面上进行区分。所以,线程所有操作函数 pthread_* 是库函数,而非系统调用。

线程优点:1. 提高程序并发性 2. 开销小 3. 数据通信、共享数据方便

线程缺点:1. 库函数,不稳定 2. 调试、编写困难、gdb不支持 3. 对信号支持不好

线程共享资源

  1. 文件描述符表

  2. 每种信号的处理方式

  3. 当前工作目录

  4. 用户ID和组ID

  5. 内存地址空间 (.text/.data/.bss/heap/共享库)。

    • 栈空间不共享,data段全局变量errno不共享。
    • 尤其注意线程共享全局变量。进程不共享全局变量,只能借助mmap。

线程不共享资源

  1. 线程id。

  2. 处理器现场和栈指针(内核栈)

  3. 独立的栈空间(用户空间栈)

  4. errno变量

  5. 信号屏蔽字

  6. 调度优先级

线程控制原语

Linux环境下,所有线程特点,失败直接返回错误号。

头文件 pthread.h

1、获取线程ID。	对应进程中 getpid() 函数。
pthread_t pthread_self(void);	返回值:线程id
参数:
    pthread_t:线程ID,在Linux下为无符号整数(%lu)。
    pthread_t在其他系统中可能是结构体、地址等实现,因此不能直接打印输出,必须用函数获取。
    
2、创建一个新线程。	对应进程中 fork() 函数。
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, 
                   void *(*start_routine) (void *), void *arg);
返回值:成功:0;   失败:错误号
作用:
    创建线程后,主线程继续执行,子线程沿着传入函数指针start_routine执行。
    子线程函数:void *(*start_routine) (void *arg)
    	start_routine接收参数arg就是pthread_create的arg
    	start_routine,返回后线程就推出了。返回值void *可以自行指定。
参数:
    pthread_t:传出参数,保存系统为我们分配好的线程ID
    pthread_attr_t:线程属性,通常传NULL表示默认。具体内容见下一节。
    start_routine:函数指针,指向线程主函数(线程体),该函数运行结束,则线程结束。
    arg:线程主函数执行期间所使用的参数。
注意:
    创建多个线程时,如果arg采用地址传递,可能导致读到的值不一致,所以最好采用值传递。
    采用强制类型转换的方法将int值变成void *,在线程再转为int就可。
    	32位系统中void *是4字节的,64位的void *是8字节的,int是4字节的。
    	在64位系统中,先是int转void*,小转大,高位补零。之后大转小,高位去零。不影响。
    
2.2、打印错误描述  
- pthread_create的错误码不保存在errno中,因此不能直接用perror(3)打印错误信息。
char *strerror(int errnum);     # 头文件 string.h
返回值:错误描述字符串
    errnum:错误号
打印到标准错误上
int fprintf(FILE *stream, const char *format, ...);
	eg:fprintf(stderr, strerror(errnum));

3、单个线程退出
void pthread_exit(void *retval);	
参数:retval表示线程退出状态,通常传NULL
注意:
    线程中尽量不用exit函数(退出整个进程)。
    pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,
    	不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
总结exit、return、pthread_exit各自退出效果。
    return:返回到调用者那里去。
    pthread_exit():将调用该函数的线程			
    exit: 将进程退出。

4、回收子线程,阻塞等待。	对应进程中 waitpid() 函数。
int pthread_join(pthread_t thread, void **retval); 
返回值: 成功:0;失败:错误号
参数: thread:线程ID;  retval:存储线程结束状态。
retval的值:
    子线程return返回,retval地址为return的值。
    子线程pthread_exit终止,retval地址为传给pthread_exit的参数。
    子线程被pthread_cancel异常终止掉,retval地址为常数PTHREAD_CANCELED=-1。
    对thread线程的终止状态不感兴趣,可以传NULL给retval参数。    	
retval的类型void **:
	进程中:main返回值、exit参数-->int;  wait(pid, &status)-->int *
    线程中:线程函数返回值、pthread_exit-->void *; pthread_join-->void **
结论:用于接收retval的应该是一个指针类型,且传入pthread_join时要再加取地址——void **。
    
5、线程分离。线程结束后,自动退出,无残留资源,无需pthread_join。
int pthread_detach(pthread_t thread);	
返回值: 成功:0;失败:错误号
    分离后的线程调用pthread_join回收,返回错误号22。
    
6、杀死(取消)线程。		回收被杀死的线程,返回PTHREAD_CANCELED=-1。
int pthread_cancel(pthread_t thread);	
返回值:成功:0;失败:错误号
注意:
    杀死线程必须到达某个取消点,通常是一些系统调用,man 7 pthreads查看。
    如:creat,open,pause,close,read,write..... 
	设置取消点: pthread_testcancel();

7、比较两个线程ID是否相等。
int pthread_equal(pthread_t t1, pthread_t t2);
linux中,线程id是整型,可以直接用 = 判断。

线程属性

主要属性:
1、线程分离状态
2、线程栈大小(默认平均分配)
3、线程栈警戒缓冲区大小(位于栈末尾)

属性值不能直接设置,须使用相关函数进行操作。

typedef struct
{
    int 				etachstate; 		//线程的分离状态
    int 				schedpolicy; 		//线程调度策略
    struct sched_param	schedparam; 		//线程的调度参数
    int 				inheritsched; 		//线程的继承性
    int 				scope; 				//线程的作用域
    size_t 				guardsize; 			//线程栈末尾的警戒缓冲区大小
    int					stackaddr_set; 		//线程的栈设置
    void* 				stackaddr; 			//线程栈的位置
    size_t 				stacksize; 			//线程栈的大小
} pthread_attr_t; 

1、线程属性初始化
初始化函数:		必须在pthread_create函数之前调用。
int pthread_attr_init(pthread_attr_t *attr); # 成功:0;失败:错误号
释放资源:
int pthread_attr_destroy(pthread_attr_t *attr); # 成功:0;失败:错误号

2、线程分离状态
设置:
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate); 
参数:
    pthread_attr_t: 初始化的线程属性
    etachstate:	
    	PTHREAD_CREATE_DETACHED(分离线程)
		PTHREAD _CREATE_JOINABLE(非分离线程)  
注意:如果设置一个线程初始化为分离线程,而这个线程运行又非常快。
    它很可能在pthread_create函数返回之前就终止了,其线程号和系统资源移交给其他的线程使用。
    这样调用pthread_create的线程就得到了错误的线程号。解决方法:
  	在线程函数中调用pthread_cond_timedwait函数,留出足够的时间让函数pthread_create返回。
获取:  没啥用
int pthread_attr_getdetachstate(pthread_attr_t *attr, int *detachstate); 
# 传出参数 detachstate

3、线程栈
3.1 设置栈地址:
int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, 
                          size_t stacksize); 
返回值:成功:0;失败:错误号
作用:
    当进程栈地址空间不够用时,指定新建线程使用由malloc分配的堆空间作为自己的栈空间。
参数:
    attr:指向一个线程属性的指针
    stackaddr:返回获取的栈地址
    stacksize:返回获取的栈大小
    
3.2 获取栈地址:
int pthread_attr_getstack(pthread_attr_t *attr, void **stackaddr, 
                          size_t *stacksize); 
返回值:成功:0;失败:错误号
    
3.3 设置栈大小:
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize); 
返回值:成功:0;失败:错误号
作用:当系统中有很多线程时,可能需要减小每个线程栈的默认大小,防止进程的地址空间不够用.
    当线程调用的函数会分配很大的局部变量或者函数调用层次很深时,可能需要增大线程栈的默认大小。

3.4 获取栈大小:
int pthread_attr_getstacksize(pthread_attr_t *attr, size_t *stacksize); 
返回值:成功:0;失败:错误号

NPTL

  1. 察看当前pthread库版本getconf GNU_LIBPTHREAD_VERSION

  2. NPTL实现机制(POSIX),Native POSIX Thread Library

  3. 使用线程库时 gcc 指定 –lpthread

线程使用注意事项

  1. 主线程退出其他线程不退出,主线程应调用pthread_exit

  2. 避免僵尸线程

    pthread_join

    pthread_detach

    pthread_create指定分离属性

    被join线程可能在join函数返回前就释放完自己的所有内存资源,所以不应当返回被回收线程栈中的值;

  3. malloc和mmap申请的内存可以被其他线程释放

  4. 应避免在多线程模型中调用fork除非,马上exec。子进程中只有调用fork的线程存在,其他线程在子进程中均pthread_exit

  5. 信号的复杂语义很难和多线程共存,应避免在多线程引入信号机制

线程同步

概念:线程调用某一功能(函数)时,在没返回前,调用不返回。同时其他线程不能调用该函数(保证数据一致性)。

数据混乱产生的原因:

  1. 资源共享。
  2. 调度随机,数据访问出现竞争。
  3. 多个对象缺少同步机制。

互斥量 mutex

访问共享数据时,先拿到锁后才访问,拿不到锁进入阻塞。

互斥锁实质是一种”建议锁/协同锁“,没有强制规定没有锁就不能访问。

在访问共享资源前加锁,访问结束后立即解锁。锁的“粒度”应越小越好。

pthread_mutex_t 类型,本质是一个结构体。可以忽略其细节,简化为两种取值1、0。
 
以下5个函数的返回值都是:成功返回0, 失败返回错误号。
1、初始化一个互斥锁(互斥量) ---> 初值可看作1
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
                       const pthread_mutexattr_t *restrict attr);
参数:pthread_mutex_t:传出参数,调用时应传 &mutex
    pthread_mutexattr_t:互斥量属性,传入参数,通常传NULL(线程间共享)
补充:restrict关键字:限制指针。所有修改该指针指向内存中内容的操作,只能通过本指针完成。
    静态初始化:mutex是静态分配的(全局,static修饰),可以直接使用宏进行初始化。
        pthead_mutex_t muetx = PTHREAD_MUTEX_INITIALIZER
    动态初始化:局部变量应采用动态初始化。e.g.  
        pthread_mutex_init(&mutex, NULL)

2、销毁一个互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
3、加锁。类似 mutex--
int pthread_mutex_lock(pthread_mutex_t *mutex);
4、解锁。类似 mutex++
int pthread_mutex_unlock(pthread_mutex_t *mutex);
5、尝试加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);
	
lock尝试加锁,如果加锁不成功,线程阻塞,阻塞到持有该互斥量的其他线程解锁为止。
unlock主动解锁函数,同时将阻塞在该锁上的所有线程全部唤醒。默认:先阻塞、先唤醒。优先级、调度。
trylock加锁失败直接返回错误号(如:EBUSY),不阻塞。

死锁

  1. 线程试图对同一个互斥量A加锁两次。

    • 第二次加锁前判断是否已经锁上了。
  2. 线程1拥有A锁,请求获得B锁;线程2拥有B锁,请求获得A锁。

    • 用trylock判断是否加锁,若无法获取所有锁,主动放弃已占有的锁。

读写锁

与互斥量类似,但读写锁允许更高的并行性。

读写锁状态:

  1. 读模式下加锁状态 (读锁)

  2. 写模式下加锁状态 (写锁)

  3. 不加锁状态

读写锁特性:写独占,读共享。写锁优先级高

  1. 写模式加锁:解锁前,所有对该锁加锁的线程都会被阻塞。
  2. 读模式加锁:其他有写锁则阻塞,全是读锁则成功。
  3. 读模式加锁:其他锁到来时,写锁优先满足。
pthread_rwlock_t类型,用于定义一个读写锁变量。
    
以下 7 个函数的返回值都是:成功返回0, 失败直接返回错误号。
1、初始化一把读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, 
                        const pthread_rwlockattr_t *restrict attr);
参数: pthread_rwlock_t:读写锁 &rwlock
	pthread_rwlockattr_t:读写锁属性,默认NULL
2、销毁一把读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
3、请求读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
4、请求写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
5、解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
6、非阻塞请求读锁
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
7、非阻塞请求写锁
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

条件变量

条件变量本身不是锁,但它也可以造成线程阻塞。

通常与互斥锁配合使用。给多线程提供一个会合的场所。

pthread_cond_t类型	用于定义条件变量
    
以下 6 个函数的返回值都是:成功返回0, 失败直接返回错误号。
1、初始化一个条件变量
int pthread_cond_init(pthread_cond_t *restrict cond, 
                      const pthread_condattr_t *restrict attr);		
参树:pthread_cond_t:条件变量
     pthread_condattr_t:条件变量属性,默认NULL
静态初始化:
	pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

2、销毁一个条件变量
int pthread_cond_destroy(pthread_cond_t *cond);

3、阻塞等待一个条件变量
int pthread_cond_wait(pthread_cond_t *restrict cond, 
                      pthread_mutex_t *restrict mutex);
作用:
	- 阻塞等待条件变量cond(参1)满足	
	- 释放已掌握的互斥锁(解锁互斥量)相当于pthread_mutex_unlock(&mutex);
			上两步为一个原子操作。
	- 当被唤醒,函数返回时,解除阻塞并重新申请获取互斥锁pthread_mutex_lock(&mutex);

4、唤醒至少一个阻塞在条件变量上的线程
int pthread_cond_signal(pthread_cond_t *cond);

5、唤醒全部阻塞在条件变量上的线程
int pthread_cond_broadcast(pthread_cond_t *cond);

6、限时等待一个条件变量
int pthread_cond_timedwait(pthread_cond_t *restrict cond, 
                           pthread_mutex_t *restrict mutex, 
                           const struct timespec *restrict abstime);
参数3:
    abstime:struct timespec结构体,绝对时间。
        struct timespec {
            time_t tv_sec;		/* seconds */ 秒
            long   tv_nsec;	/* nanosecondes*/ 纳秒
        }

时间类型:
	绝对时间,相对1970年1月1日 00:00:00秒的时间。如:time(NULL)
	相对时间,相对当前时间。如:alarm(1)
eg1:
	struct timespec t = {1, 0};
	pthread_cond_timedwait (&cond, &mutex, &t);  # 定时到1970年过1秒。 
eg2:
	time_t cur = time(NULL); 		# 获取当前时间。
	struct timespec t;				# 定义timespec 结构体变量t
	t.tv_sec = cur+1; 				# 定时1秒
	pthread_cond_timedwait (&cond, &mutex, &t); # 定时到当前时间过1秒。
回顾:
	setitimer函数时用的另外一种时间类型:
        struct timeval {
             time_t      tv_sec;  /* seconds */ 秒
             suseconds_t tv_usec; 	/* microseconds */ 微秒
        };

生产者消费者条件变量模型

假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。

两个线程同时操作一个共享资源(一般称之为汇聚),生产向其中添加产品,消费者从中消费掉产品。

/*借助条件变量模拟 生产者-消费者 问题*/
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>

/*链表作为公享数据,需被互斥量保护*/
struct msg {
    struct msg *next;
    int num;
};

struct msg *head;
struct msg *mp;

/* 静态初始化 一个条件变量 和 一个互斥量*/
pthread_cond_t has_product = PTHREAD_COND_INITIALIZER;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *consumer(void *p)
{
    for (;;) {
        pthread_mutex_lock(&lock);
        while (head == NULL) {           //头指针为空,说明没有节点    可以为if吗
            pthread_cond_wait(&has_product, &lock);
        }
        mp = head;      
        head = mp->next;    //模拟消费掉一个产品
        pthread_mutex_unlock(&lock);

        printf("-Consume ---%d\n", mp->num);
        free(mp);
        mp = NULL;
        sleep(rand() % 5);
    }
}

void *producer(void *p)
{
    for (;;) {
        mp = malloc(sizeof(struct msg));
        mp->num = rand() % 1000 + 1;        //模拟生产一个产品
        printf("-Produce ---%d\n", mp->num);

        pthread_mutex_lock(&lock);
        mp->next = head;
        head = mp;
        pthread_mutex_unlock(&lock);

        pthread_cond_signal(&has_product);  //将等待在该条件变量上的一个线程唤醒
        sleep(rand() % 5);
    }
}

int main(int argc, char *argv[])
{
    pthread_t pid, cid;
    srand(time(NULL));

    pthread_create(&pid, NULL, producer, NULL);
    pthread_create(&cid, NULL, consumer, NULL);

    pthread_join(pid, NULL);
    pthread_join(cid, NULL);

    return 0;
}

条件变量的优点:

相较于mutex而言,条件变量可以减少竞争。

如果直接使用mutex,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量。

如果汇聚(链表)中没有数据,消费者之间竞争互斥锁是无意义的。

有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的竞争。提高了程序效率。

信号量 semaphore

进化版的互斥锁(1 --> N)。和 信号 完全无关。

  • 由于互斥锁的粒度比较大,如果我们希望在多个线程间对某一对象的部分数据进行共享,使用互斥锁是没有办法实现的,只能将整个数据对象锁住。

  • 这样虽然达到了多线程操作共享数据时保证数据正确性的目的,却无形中导致线程的并发性下降。线程从并行执行,变成了串行执行。与直接使用单进程无异。

信号量,是相对折中的一种处理方式,既能保证同步,数据不混乱,又能提高线程并发。

sem_t类型,本质是结构体。应用期间可简单看作为整数,忽略实现细节(类似于使用文件描述符)。 
sem_t sem; 规定信号量sem不能 < 0。头文件 <semaphore.h>

信号量基本操作:
sem_wait:	将信号量--		(类比pthread_mutex_lock)
    1. 信号量大于0,则信号量--		
    2. 信号量等于0,造成线程阻塞
sem_post:	将信号量++,唤醒阻塞在信号量上的线程	(类比pthread_mutex_unlock)
    1. 由于sem_t的实现对用户隐藏,不能直接++、--符号,只能通过函数来实现。
    2. 信号量的初值,决定了占用信号量的线程的个数。

以下 6 个函数的返回值都是:成功返回0, 失败返回-1,同时设置errno。
1、初始化一个信号量
int sem_init(sem_t *sem, int pshared, unsigned int value);
    参1:sem:		信号量	
    参2:pshared:	取0用于线程间;取非0(一般为1)用于进程间	
    参3:value:	指定信号量初值

2、销毁一个信号量
int sem_destroy(sem_t *sem);

3、给信号量加锁 -- 
int sem_wait(sem_t *sem);

4、给信号量解锁 ++
int sem_post(sem_t *sem);	

5、尝试对信号量加锁 --
int sem_trywait(sem_t *sem);	

6、限时尝试对信号量加锁 --
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
	参2:abs_timeout采用的是绝对时间。			
	定时1秒:
		time_t cur = time(NULL); 获取当前时间。
		struct timespec t;	定义timespec 结构体变量t
		t.tv_sec = cur+1; 定时1秒
		t.tv_nsec = t.tv_sec +100; 
		sem_timedwait(&sem, &t); 传参

文件锁

借助 fcntl函数来实现锁机制。
操作文件的进程没有获得锁时,可以打开,但无法执行read、write操作。

  • 多线程无法使用文件锁:多线程间共享文件描述符,而给文件加锁,是通过修改文件描述符所指向的文件结构体中的成员变量来实现的。因此,多线程中无法使用文件锁。
获取、设置文件访问控制属性。
int fcntl(int fd, int cmd, ... /* arg */ );
# arg是变参,填什么,填多少,取决于最后一个固参,也就是cmd
	参2:
		F_SETLK (struct flock *)	设置文件锁(trylock)
		F_SETLKW (struct flock *) 	设置文件锁(lock)W --> wait
		F_GETLK (struct flock *)	获取文件锁
	参3:
        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)
              ...
         };

哲学家用餐问题

避免死锁的方法:

​ 1. 当得不到所有所需资源时,放弃已经获得的资源,等待。

​ 2. 保证资源的获取顺序,要求每个线程获取资源的顺序一致。如:A获取顺序1、2、3;B顺序应也是1、2、3。若B为3、2、1则易出现死锁现象。

注意:进程间通信为什么不能用全局变量。而应该放置于mmap共享映射区中。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值