系统信息与系统资源
本章重点学习如何通过Linux系统调用或C库函数获取系统信息,譬如获取系统时间、日期
以及设置系统时间、日期等;除此之外,还会学习Linux系统下的/proc虚拟文件系统,包括/proc 文件系统是什么以及如何从/proc文件系统中读取系统、进程有关信息。
系统信息
系统标识uname
系统调用uname()用于获取有关当前操作系统内核的名称和信息,原型如下:
#include <sys/utsname.h>int uname(struct utsname *buf);
函数参数和返回值含义如下:
- buf:struct utsname结构体类型指针,指向一个struct utsname结构体类型对象。
- 返回值:成功返回0;失败将返回-1,并设置errno。
sysinfo函数
sysinfo系统调用可用于获取一些系统统计信息,其函数原型如下所示:
#include <sys/sysinfo.h>int sysinfo(struct sysinfo *info);
函数参数和返回值含义如下:
- info:struct sysinfo结构体类型指针,指向一个struct sysinfo结构体类型对象。
- 返回值:成功返回0;失败将返回-1,并设置errno。
gethostname函数
此函数可用于单独获取Linux系统主机名,与struct utsname数据结构体中的nodename变量一样,原型如下:
#include <unistd.h>int gethostname(char *name, size_t len);
函数参数和返回值含义如下:
- name:指向用于存放主机名字符串的缓冲区。
- len:缓冲区长度。
- 返回值:成功返回0,;失败将返回-1,并会设置errno。
sysconf函数
sysconf()函数是一个库函数,可在运行时获取系统的一些配置信息,函数原型如下所示:
#include <unistd.h>long sysconf(int name);
参数name指定了要获取哪个配置信息,可通过宏定义来获取配置信息。用的比较多的是_SC_PAGESIZE和_SC_CLK_TCK来获取系统页大小和系统节拍率。
时间、日期
Ubuntu系统下可通过“date”命令查看系统当前时间。时区信息通常以标准格式保存在一些文件当中,这些文件通常于/usr/share/zoneinfo目录下,该目录下的每一个文件(包括子目录下的文件)都包含了一个特定国家或地区内时区制度的相关信息,且往往根据城市或地区缩写来命名。
Linux系统中时间
Linux系统在开机启动之后首先会读取RTC硬件获取实时时钟作为系统时钟的初始值,之后内核便开始维护自己的系统时钟。
jiffies是内核中定义的一个全局变量,内核使用jiffies来记录系统从启动以来的系统节拍数,操作系统使用jiffies这个全局变量来记录当前时间。
获取时间time/gettimeofday
系统调用time()用于获取当前时间,以秒为单位,返回得到的值是自1970-01-01 00:00:00 +0000(UTC)以来的秒数,其函数原型如下所示:
#include <time.h>time_t time(time_t *tloc);
函数参数和返回值含义如下:
- tloc:如果tloc参数不是NULL,则返回值也存储在tloc指向的内存中。
- 返回值:成功则返回自1970-01-01 00:00:00 +0000(UTC)以来的时间值(以秒为单位);失败则返回-1,并会设置errno。
系统调用gettimeofday()函数提供微秒级时间精度,函数原型如下所示:
#include <sys/time.h>int gettimeofday(struct timeval *tv, struct timezone *tz);
函数参数和返回值含义如下:
- tv:参数tv是一个struct timeval结构体指针变量。
- tz:参数tz是个历史产物,早期实现用其来获取系统的时区信息,目前已遭废弃,在调用 gettimeofday()函数时应将参数tz设置为NULL。
- 返回值:成功返回0;失败将返回-1,并设置errno。
时间转换函数
ctime()是一个C库函数,可以将日历时间转换为可打印输出的字符串形式,原型如下所示:
#include <time.h>char *ctime(const time_t *timep);
char *ctime_r(const time_t *timep, char *buf);
函数参数和返回值含义如下:
- timep:time_t时间变量指针。
- 返回值:成功将返回一个char *类型指针,指向转换后得到的字符串;失败将返回NULL。
ctime_r()是ctime()的可重入版本,一般推荐使用可重入函数ctime_r(),可重入函数ctime_r()多了一个参数buf,也就是缓冲区首地址,所以ctime_r()函数需要调用者提供用于存放字符串的缓冲区。
localtime()函数可以把time()或gettimeofday()得到的秒数(time_t 时间或日历时间)变成一个 struct tm结构体所表示的时间,该时间对应的是本地时间。原型如下:
#include <time.h>struct tm *localtime(const time_t *timep);
struct tm *localtime_r(const time_t *timep, struct tm *result);
函数参数和返回值含义如下:
- timep:需要进行转换的time_t时间变量对应的指针,可通过time()或gettimeofday()获取得到。
- result:是一个struct tm结构体类型指针,参数result是可重入函数localtime_r()需要额外提供的参数。
- 返回值:对于不可重入版本localtime()来说,成功则返回一个有效的struct tm结构体指针,而对于可重入版本localtime_r()来说,成功执行情况下,返回值将会等于参数result;失败则返回NULL。
gmtime()函数也可以把time_t时间变成一个struct tm结构体所表示的时间,与localtime()所不同的是,gmtime()函数所得到的是UTC国际标准时间,原型如下:
#include <time.h>struct tm *gmtime(const time_t *timep);
struct tm *gmtime_r(const time_t *timep, struct tm *result);
该函数的参数和返回值与localtime()是一样的。
C库函数mktime()函数与localtime()函数相反,mktime()可以将使用struct tm结构体表示的分解时间转换为time_t时间(日历时间),原型如下:
#include <time.h>time_t mktime(struct tm *tm);
函数参数和返回值含义如下:
- tm:需要进行转换的struct tm结构体变量对应的指针。
- 返回值:成功返回转换得到time_t时间值;失败返回-1。
asctime()函数与ctime()函数的作用一样,也可将时间转换为可打印输出的字符串形式,与ctime()函数的区别在于,ctime()是将time_t时间转换为固定格式字符串、而asctime()则是将struct tm表示的分解时间转换为固定格式的字符串。原型如下所示:
#include <time.h>char *asctime(const struct tm *tm);
char *asctime_r(const struct tm *tm, char *buf);
函数参数和返回值含义如下:
- tm:需要进行转换的struct tm表示的时间。
- buf:可重入版本函数asctime_r需要额外提供的参数buf,指向一个缓冲区,用于存放转换得到的字符串。
- 返回值:转换失败将返回NULL;成功将返回一个char *类型指针,指向转换后得到的时间字符串,对于asctime_r函数来说,返回值就等于参数buf。
C库函数strftime(),此函数也可以将一个struct tm变量表示的分解时间转换为为格式化字符串,并且可以根据自己的喜好自定义时间的显示格式,原型如下:
#include <time.h>size_t strftime(char *s, size_t max, const char *format, const struct tm *tm);
函数参数和返回值含义如下:
- s:指向一个缓存区的指针,该缓冲区用于存放生成的字符串。
- max:字符串的最大字节数。
- format:这是一个用字符串表示的字段,包含了普通字符和特殊格式说明符,可以是这两种字符的任意组合。
- tm:指向struct tm结构体对象的指针。
- 返回值:如果转换得到的目标字符串不超过最大字节数(也就是max),则返回放置到s数组中的字节数;如果超过了最大字节数,则返回0。
设置时间settimeofday
settimeofday()函数可以设置时间,也就是设置系统的本地时间,函数原型如下所示:
#include <sys/time.h>int settimeofday(const struct timeval *tv, const struct timezone *tz);
函数参数和返回值含义如下:
- tv:参数tv是一个struct timeval结构体指针变量,需要设置的时间便通过参数tv指向的struct timeval结构体变量传递进去。
- tz:参数tz是个历史产物,早期实现用其来设置系统的时区信息,目前已遭废弃,在调用settimeofday()函数时应将参数tz设置为NULL。
- 返回值:成功返回0;失败将返回-1,并设置errno。
只有超级用户(root)才可以设置系统时间。
总结
Linux系统下时间相关函数使用总结如下:
进程时间
进程时间指的是进程从创建后(也就是程序运行后)到目前为止这段时间内使用CPU资源的时间总数,出于记录的目的,内核把CPU时间分为以下两个部分:
- 用户CPU时间:进程在用户空间(用户态)下运行所花费的CPU时间。有时也称为虚拟时间(virtual time)。
- 系统CPU时间:进程在内核空间(内核态)下运行所花费的CPU时间。这是内核执行系统调用或代表进程执行的其它任务所花费的时间。
休眠并不会计算在进程时间中,因为并没有使用CPU资源。
times函数
times()函数用于获取当前进程时间,其函数原型如下所示:
#include <sys/times.h>clock_t times(struct tms *buf);
函数参数和返回值含义如下:
- buf:times()会将当前进程时间信息存在一个struct tms结构体数据中,使用参数buf指向该变量。
- 返回值:返回值类型为clock_t(实质是long 类型),调用成功情况下,将返回从过去任意的一个时间点所经过的时钟滴答数,将(节拍数/节拍率)便可得到秒数,返回值可能会超过clock_t所能表示的范围;调用失败返回-1,并设置errno。
clock函数
库函数clock()提供了一个更为简单的方式用于进程时间,它的返回值描述了进程使用的总的CPU时间,其函数原型如下所示:
#include <time.h>clock_t clock(void);
函数参数和返回值含义如下:
- 无参数。
- 返回值:返回值是到目前为止程序的进程时间,为clock_t类型,注意clock()的返回值并不是系统节拍数,如果想要获得秒数,请除以 CLOCKS_PER_SEC(这是一个宏)。如果返回的进程时间不可用或其值无
法表示,则该返回值是-1。
产生随机数
rand()函数用于获取随机数,多次调用rand()可得到一组随机数序列,其函数原型如下:
#include <stdlib.h>int rand(void);
函数参数和返回值含义如下:
- 返回值:返回一个介于0到RAND_MAX(包含)之间的值。
使用srand()函数为rand()设置随机数种子,其函数原型如下所示:
#include <stdlib.h>void srand(unsigned int seed);
函数参数和返回值含义如下:
- seed:指定一个随机数中,int类型的数据,一般将当前时间作为随机数种子赋值给参数seed。
- 返回值:无返回值。
常用的用法srand(time(NULL));
休眠
秒级休眠:sleep
sleep()是一个C库函数,原型如下:
#include <unistd.h>unsigned int sleep(unsigned int seconds);
函数参数和返回值含义如下:
- seconds:休眠时长,以秒为单位。
- 返回值:如果休眠时长为参数seconds所指定的秒数,则返回0;若被信号中断则返回剩余的秒数。
微秒级休眠:usleep
usleep()同样也是一个C库函数,支持微秒级程序休眠,其函数原型如下所示:
#include <unistd.h>int usleep(useconds_t usec);
函数参数和返回值含义如下:
- usec:休眠时长,以微秒为单位。
- 返回值:成功返回0;失败返回-1,并设置errno。
高精度休眠: nanosleep
系统调用nanosleep()具有更高精度来设置休眠时间长度,支持纳秒级时长设置,原型如下:
#include <time.h>int nanosleep(const struct timespec *req, struct timespec *rem);
函数参数与返回值含义如下:
- req:一个struct timespec结构体指针,指向一个struct timespec变量,用于设置休眠时间长度,可精确到纳秒级别。
- rem:也是一个struct timespec结构体指针,指向一个struct timespec变量,也可设置NULL。
- 返回值:在成功休眠达到请求的时间间隔后,nanosleep()返回0;如果中途被信号中断或遇到错误,则返回-1,并将剩余时间记录在参数rem指向的struct timespec结构体变量中,还会设置errno标识错误类型。
应用程序当中使用休眠用作延时功能,并不是裸机程序中的nop空指令延时,一旦执行sleep(),进程便主动交出CPU使用权,暂时退出系统调度队列,在休眠结束前,该进程的指令将得不到执行。
申请堆内存
堆上分配:malloc和free
Linux C程序当中一般使用malloc()函数为程序分配一段堆内存,而使用free()函数来释放这段内存,malloc原型如下:
#include <stdlib.h>void *malloc(size_t size);
函数参数和返回值含义如下:
- size:需要分配的内存大小,以字节为单位。
- 返回值:返回值为void *类型,如果申请分配内存成功,将返回一个指向该段内存的指针,void *并不是说没有返回值或者返回空指针,而是返回的指针类型未知,所以在调用malloc()时通常需要进行强制类型转换,将void *指针类型转换成希望的类型;如果分配内存失败将返回NULL,如果参数size为0,返回值也是NULL。
通常需要对malloc()分配的堆内存进行初始化操作。
手动释放函数free()原型如下:
#include <stdlib.h>void free(void *ptr);
函数参数和返回值含义如下:
- ptr:指向需要被释放的堆内存对应的指针。
- 返回值:无返回值。
堆上分配的其他方法
calloc()函数用来动态地分配内存空间并初始化为0,其函数原型如下所示:
#include <stdlib.h>void *calloc(size_t nmemb, size_t size);
- calloc()在堆中动态地分配nmemb个长度为size的连续空间,并将每一个字节都初始化为0。
- 返回值:分配成功返回指向该内存的地址,失败则返回NULL。
分配对齐内存
C函数库中还提供了一系列在堆上分配对齐内存的函数,对齐内存在某些应用场合非常有必要,常用于分配对其内存的库函数有posix_memalign()、aligned_alloc()、memalign()、valloc()、pvalloc(),它们的函数原型如下所示:
#include <stdlib.h>int posix_memalign(void **memptr, size_t alignment, size_t size);
void *aligned_alloc(size_t alignment, size_t size);
void *valloc(size_t size);#include <malloc.h>void *memalign(size_t alignment, size_t size);
void *pvalloc(size_t size);
posix_memalign()函数用于在堆上分配size个字节大小的对齐内存空间,将*memptr指向分配的空间,分配的内存地址将是参数alignment的整数倍。参数alignment表示对齐字节数,alignment必须是2的幂次方,同时也要是sizeof(void *)的整数倍,对于32位系统来说,sizeof(void *)等于4,如果是64位系统sizeof(void *)等于8。
函数参数和返回值含义如下:
- memptr:void **类型的指针,内存申请成功后会将分配的内存地址存放在*memptr 中。
- alignment:设置内存对其的字节数。
- size:设置分配的内存大小,以字节为单位,如果参数size等于0,那么*memptr 中的值是NULL。
- 返回值:成功将返回0;失败返回非0值。
aligned_alloc()函数用于分配size个字节大小的内存空间,返回指向该空间的指针。
函数参数和返回值含义如下:
- alignment:用于设置对齐字节大小。
- size:设置分配的内存大小,以字节为单位。参数size必须是参数alignment的整数倍。
- 返回值:成功将返回内存空间的指针,内存空间的起始地址是参数alignment的整数倍;失败返回NULL。
memalign()函数已经过时了,并不提倡使用!
valloc()函数已经过时了,并不提倡使用!
proc文件系统
proc文件系统是一个虚拟文件系统,它以文件系统的方式为应用层访问系统内核数据提供了接口,用户和应用程序可以通过proc文件系统得到系统信息和进程相关信息,对proc文件系统的读写作为与内核进行通信的一种手段。但是与普通文件不同的是,proc文件系统是动态创建的,文件本身并不存在于磁盘当中、只存在于内存当中,与devfs一样,都被称为虚拟文件系统。
内核构建proc虚拟文件系统,它会将内核运行时的一些关键数据信息以文件的方式呈现在proc文件系统下的一些特定文件中,这样相当于将一些不可见的内核中的数据结构以可视化的方式呈现给应用层。
proc文件系统挂载在系统的/proc目录下,相当于提供了一种调试内核的方法:通过查看/proc/xxx文件来获取到内核特定数据结构的值,在添加了新功能前后进行对比,就可以判断此功能所产生的影响是否合理。
proc文件系统使用
proc文件系统的使用就是去读取/proc目录下的这些文件,获取文件中记录的信息,可以直接使用cat命令读取,也可以在应用程序中调用open()打开、然后再使用read()函数读取。
信号:基础
在很多应用程序当中,都会存在处理异步事件这种需求,而信号提供了一种处理异步事件的方法。
基本概念
信号是事件发生时对进程的通知机制,也可以把它称为软件中断。信号与硬件中断的相似之处在于能够打断程序当前执行的正常流程,其实是在软件层次上对中断机制的一种模拟。大多数情况下,是无法预测信号达到的准确时间,所以,信号提供了一种处理异步事件的方法。
信号的目的就是用来通信的,在硬件发生异常、终端输入能产生信号的特殊字符、进程调用kill()系统调用、kill命令发送信号、软件事件等均可产生信号。
信号被对应进程接受后,可以忽略信号、捕捉信号、也可以执行系统默认操作。
信号是异步的,只有触发了信号才会打断当前程序正常执行流程转而处理信号触发后事件。
信号本质就是int类型数字编号。信号都定义在<signum.h>头文件中,编号从1开始。
信号分类
可靠信号与不可靠信号
Linux支持不可靠信号,但是对不可靠信号机制做了改进:在调用完信号处理函数后,不必重新调用signal()。因此,Linux下的不可靠信号问题主要指的是信号可能丢失。在Linux系统下,信号值小于SIGRTMIN(34)的信号都是不可靠信号,这就是"不可靠信号"的来源,因此之前<signum.h>中的信号均为不可靠信号。
之后又新增加了许多信号(SIGRTMIN-SIGRTMAX),并定义为可靠信号,在Linux系统下可用“kill -l”查看所有信号,如下所示:
实时信号与非实时信号
非实时信号都不支持排队,都是不可靠信号;实时信号都支持排队,都是可靠信号。实时信号保证了发送的多个信号都能被接收,实时信号是POSIX标准的一部分,可用于应用进程。
一般会将非实时信号(不可靠信号)称为标准信号。
常见信号与默认行为
- SIGINT
当用户在终端按下中断字符(通常是CTRL + C)时,内核将发送SIGINT信号给前台进程组中的每一个进程。该信号的系统默认操作是终止进程的运行。
- SIGQUIT
当用户在终端按下退出字符(通常是CTRL + )时,内核将发送SIGQUIT信号给前台进程组中的每一个进程。该信号的系统默认操作是终止进程的运行、并生成可用于调试的核心转储文件。一般用于进程陷入无限循环等情况下。
- SIGILL
如果进程试图执行非法(即格式不正确)的机器语言指令,系统将向进程发送该信号。该信号的系统默认操作是终止进程的运行。
- SIGABRT
当进程调用abort()系统调用时(进程异常终止),系统会向该进程发送SIGABRT信号。该信号的系统默认操作是终止进程、并生成核心转储文件。
- SIGBUS
产生该信号(总线错误,bus error)表示发生了某种内存访问错误。该信号的系统默认操作是终止进程。
- SIGFPE
该信号因特定类型的算术错误而产生。该信号的系统默认操作是终止进程。
- SIGKILL
此信号为“必杀(sure kill)”信号,用于杀死进程的终极办法,此信号无法被进程阻塞、忽略或者捕获,总能终止进程。前提条件是该进程并没有忽略或捕获这些信号,如果使用SIGINT或SIGQUIT无法终止进程,那就使用SIGKILL。Linux下有一个kill命令,kill命令可用于向进程发送信号,会使用"kill -9 xxx"命令来终止一个进程(xxx 表示进程的pid),这里的-9其实指的就是发送编号为9的信号,也就是SIGKILL信号。
- SIGUSR1
该信号和SIGUSR2信号供程序员自定义使用,内核绝不会为进程产生这些信号,在程序中,可以使用这些信号来互通通知事件的发生,或是进程彼此同步操作。该信号的系统默认操作是终止进程。
- SIGSEGV
当应用程序对内存的引用无效时,操作系统就会向该应用程序发送该信号。该信号的系统默认操作是终止进程。
- SIGUSR2
与SIGUSR1信号相同。
- SIGPIPE
涉及到管道和socket,当进程向已经关闭的管道、FIFO或套接字写入信息时,那么系统将发送该信号给进程。该信号的系统默认操作是终止进程。
- SIGALRM
与系统调用alarm()或setitimer()有关,应用程序中可以调用alarm()或setitimer()函数来设置一个定时器,当定时器定时时间到,那么内核将会发送SIGALRM信号给该应用程序。该信号的系统默认操作是终止进程。
- SIGTERM
这是用于终止进程的标准信号,也是 kill 命令所发送的默认信号(kill xxx,xxx表示进程 pid),有时会直接使用"kill -9 xxx"显式向进程发送SIGKILL信号来终止进程,然而这一做法通常是错误的,这种方式应该作为最后手段,应首先尝试使用SIGTERM,实在不行再使用最后手段SIGKILL。
- SIGCHLD
当父进程的某一个子进程终止时,内核会向父进程发送该信号。当父进程的某一个子进程因收到信号而停止或恢复时,内核也可能向父进程发送该信号。该信号的系统默认操作是忽略此信号,如果父进程希望被告知其子进程的这种状态改变,则应捕获此信号。
- SIGCLD
与SIGCHLD信号同义。
- SIGCONT
将该信号发送给已停止的进程,进程将会恢复运行。当进程接收到此信号时并不处于停止状态,系统默认操作是忽略该信号,但如果进程处于停止状态,则系统默认操作是使该进程继续运行。
- SIGSTOP
这是一个“必停”信号,用于停止进程,应用程序无法将该信号忽略或者捕获,故而总能停止进程。
- SIGTSTP
这也是一个停止信号,当用户在终端按下停止字符(通常是CTRL + Z),那么系统会将SIGTSTP信号发送给前台进程组中的每一个进程,使其停止运行。
- SIGXCPU
当进程的CPU时间超出对应的资源限制时,内核将发送此信号给该进程。
- SIGVTALRM
应用程序调用setitimer()函数设置一个虚拟定时器,当定时器定时时间到时,内核将会发送该信号给进程。
- SIGWINCH
在窗口环境中,当终端窗口尺寸发生变化时,系统会向前台进程组中的每一个进程发送该信号。
- SIGPOLL/SIGIO
这两个信号同义。用于提示一个异步IO事件的发生,内核会向应用程序发送SIGIO信号。
- SIGSYS
如果进程发起的系统调用有误,那么内核将发送该信号给对应的进程。
以上总结可如下图所示:
进程对信号的处理
Linux系统提供了系统调用signal()和sigaction()两个函数用于设置信号的处理方式。
signal()函数
signal()函数是Linux系统下设置信号处理方式最简单的接口,可将信号的处理方式设置为捕获信号、忽略信号以及系统默认操作,此函数原型如下所示:
#include <signal.h>typedef void (*sig_t)(int);
sig_t signal(int signum, sig_t handler);
函数参数和返回值含义如下:
- signum:此参数指定需要进行设置的信号,可使用信号名(宏)或信号的数字编号,建议使用信号名。
- handler:sig_t类型的函数指针,指向信号对应的信号处理函数,当进程接收到信号后会自动执行该处理函数;参数handler既可以设置为用户自定义的函数,也就是捕获信号时需要执行的处理函数,也可以设置为SIG_IGN或SIG_DFL,SIG_IGN表示此进程需要忽略该信号,SIG_DFL则表示设置为系统默认操作。sig_t函数指针的int类型参数指的是,当前触发该函数的信号,可将多个信号绑定到同一个信号处理函数上,此时就可通过此参数来判断当前触发的是哪个信号。
- 返回值:此函数的返回值也是一个sig_t类型的函数指针,成功情况下的返回值则是指向在此之前的信号处理函数;如果出错则返回SIG_ERR,并会设置errno。
signal()的测试中,可以看到如果是后台执行,那么按下中断符时系统是不会给后台进程发送SIGINT信号的。
- 程序启动
当一个应用程序刚启动的时候(或者程序中没有调用signal()函数),通常情况下,进程对所有信号的处理方式都设置为系统默认操作。
- 进程创建
当一个进程调用fork()创建子进程时,其子进程将会继承父进程的信号处理方式,因为子进程在开始时复制了父进程的内存映像,所以信号捕获函数的地址在子进程中是有意义的。
sigaction()函数
sigaction()允许单独获取信号的处理函数而不是设置,并且还可以设置各种属性对调用信号处理函数时的行为施以更加精准的控制,其函数原型如下所示:
#include <signal.h>int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
函数参数和返回值含义如下:
- signum:需要设置的信号,除了SIGKILL信号和SIGSTOP信号之外的任何信号。
- act:act参数是一个struct sigaction类型指针,指向一个struct sigaction数据结构,该数据结构描述了信号的处理方式;如果参数act不为NULL,则表示需要为信号设置新的处理方式;如果参数act为NULL,则表示无需改变信号当前的处理方式。
- oldact:oldact参数也是一个struct sigaction类型指针,指向一个struct sigaction数据结构。如果参数oldact不为NULL,则会将信号之前的处理方式等信息通过参数oldact返回出来;如果无意获取此类信息,那么可将该参数设置为NULL。
- 返回值:成功返回0;失败将返回-1,并设置errno。
向进程发送信号
Linux系统提供了kill()系统调用,一个进程可通过kill()向另一个进程发送信号;除了kill()系统调用之外,Linux系统还提供了系统调用killpg()以及库函数raise(),也可用于实现发送信号的功能。
kill()函数
kill()系统调用可将信号发送给指定的进程或进程组中的每一个进程,其函数原型如下所示:
#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, int sig);
函数参数和返回值含义如下:
- pid:参数pid为正数的情况下,用于指定接收此信号的进程pid;除此之外,参数pid也可设置为0或-1以及小于-1等不同值。
- sig:参数sig指定需要发送的信号,也可设置为0,如果参数sig设置为0则表示不发送信号,但仍执行错误检查,这通常可用于检查参数pid指定的进程是否存在。
- 返回值:成功返回0;失败将返回-1,并设置errno。
pid为正就是发送到该pid的进程;pid为0就是发送到当前进程的进程组的每个进程;pid为-1就是发送到当前进程有权发送信号的每个进程;pid小于-1就是发送到-pid进程组的每个进程。
raise()函数
C库函数raise()函数可用于向自身发送信号,原型如下:
#include <signal.h>int raise(int sig);
函数参数和返回值含义如下:
- sig:需要发送的信号。
- 返回值:成功返回0;失败将返回非零值。
raise()其实等价于:kill(getpid(),sig);
alarm()和pause()函数
alarm()函数
alarm()函数可以设置一个定时器,当定时器定时时间到时,内核会向进程发送SIGALRM信号,其函数原型如下所示:
#include <unistd.h>unsigned int alarm(unsigned int seconds);
函数参数和返回值:
- seconds:设置定时时间,以秒为单位;如果参数seconds等于0,则表示取消之前设置的alarm闹钟。
- 返回值:如果在调用alarm()时,之前已经为该进程设置了alarm闹钟还没有超时,则该闹钟的剩余值作为本次alarm()函数调用的返回值,之前设置的闹钟则被新的替代;否则返回0。
每个进程只能设置一个alarm闹钟,且alarm闹钟不能循环触发。
pause()函数
pause()系统调用可以使得进程暂停运行、进入休眠状态,直到进程捕获到一个信号为止,只有执行了信号处理函数并从其返回时,pause()才返回,在这种情况下,pause()返回-1,并且将errno设置为EINTR。其函数原型如下所示:
#include <unistd.h>int pause(void);
信号集
通常需要有一个能表示多个信号(一组信号)的数据类型—信号集(signalset),很多系统调用都使用到了信号集这种数据类型来作为参数传递,譬如sigaction()函数、sigprocmask()函数、sigpending()函数等。
信号集其实就是sigset_t类型数据结构,如下:
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{unsigned long int __val[_SIGSET_NWORDS];
} sigset_t;
初始化信号集
sigemptyset()和sigfillset()用于初始化信号集。sigemptyset()初始化信号集,使其不包含任何信号;而sigfillset()函数初始化信号集,使其包含所有信号(包括所有实时信号),函数原型如下:
#include <signal.h>int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
函数参数和返回值含义如下:
- set:指向需要进行初始化的信号集变量。
- 返回值:成功返回0;失败将返回-1,并设置errno。
向信号集添加/删除信号
分别使用sigaddset()和sigdelset()函数向信号集中添加或移除一个信号,函数原型如下:
#include <signal.h>int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
函数参数和返回值含义如下:
- set:指向信号集。
- signum:需要添加/删除的信号。
- 返回值:成功返回0;失败将返回-1,并设置errno。
测试信号是否属于信号集
sigismember()函数可以测试某一个信号是否在指定的信号集中,函数原型如下所示:
#include <signal.h>int sigismember(const sigset_t *set, int signum);
函数参数和返回值含义如下:
- set:指定信号集。
- signum:需要进行测试的信号。
- 返回值:如果信号signum在信号集set中,则返回1;如果不在信号集set中,则返回0;失败则返回-1,并设置errno。
获取信号描述信息
Linux下,每个信号都有一串与之相对应的字符串描述信息,用于对该信号进行相应的描述。*这些字符串位于sys_siglist数组中,sys_siglist数组是一个char 类型的数组,数组中的每一个元素存放的是一个字符串指针,指向一个信号描述信息。
使用时需要包含<signal.h>头文件。
strsignal()函数
strsignal()函数可获取描述信息,原型如下:
#include <string.h>char *strsignal(int sig);
调用strsignal()函数将会获取到参数sig指定的信号对应的描述信息,返回该描述信息字符串的指针。
psignal()函数
psignal()可以在标准错误(stderr)上输出信号描述信息,其函数原型如下所示:
#include <signal.h>void psignal(int sig, const char *s);
调用psignal()函数会将参数sig指定的信号对应的描述信息输出到标准错误,并且还允许调用者添加一些输出信息,由参数s指定。
信号掩码(阻塞信号传递)
内核为每一个进程维护了一个信号掩码(其实就是一个信号集)。当进程接收到一个属于信号掩码中定义的信号时,该信号将会被阻塞、无法传递给进程进行处理,那么内核会将其阻塞,直到该信号从信号掩码中移除,内核才会把该信号传递给进程从而得到处理。
调用signal()或sigaction()函数为某信号设置处理方式,进程会自动将该信号加入信号掩码;sigaction()可额外指定一组信号,调用信号处理函数时将该组信号加入信号掩码中,信号处理函数结束后自动移除;sigprocmask()系统调用也可显式操作信号掩码。
sigprocmask()函数原型如下所示:
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
函数参数和返回值含义如下:
- how:参数how指定了调用函数时的一些行为。
- set:将参数set指向的信号集内的所有信号添加到信号掩码中或者从信号掩码中移除;如果参数set为NULL,则表示无需对当前信号掩码作出改动。
- oldset:如果参数oldset不为NULL,在向信号掩码中添加新的信号之前,获取到进程当前的信号掩码,存放在oldset所指定的信号集中;如果为NULL则表示不获取当前的信号掩码。
- 返回值:成功返回0;失败将返回-1,并设置errno。
阻塞等待信号sigsuspend()
如果希望对一个信号解除阻塞后,然后调用pause()以等待之前被阻塞的信号的传递,这就需要将恢复信号掩码和pause()挂起进程封装为一个原子操作,也就是sigsuspend(),原型如下:
#include <signal.h>int sigsuspend(const sigset_t *mask);
函数参数和返回值含义如下:
- mask:参数mask指向一个信号集。
- 返回值:sigsuspend()始终返回-1,并设置errno来指示错误(通常为EINTR),表示被信号所中断,如果调用失败,将errno设置为EFAULT。
实时信号
为了确定进程中处于等待状态的是哪些信号,可以使用sigpending()函数获取。
sigpending()函数
原型如下:
#include <signal.h>int sigpending(sigset_t *set);
函数参数和返回值含义如下:
- set:处于等待状态的信号会存放在参数set所指向的信号集中。
- 返回值:成功返回0;失败将返回-1,并设置errno。
发送实时信号
等待信号集只能表明信号是否发生,而无法判断发生了几次。实时信号就有如下优势:
- 信号范围广
- 内核采取队列化管理,会多次传递
- 可为信号指定伴随数据(整型或指针值)
- 不同实时信号的传递顺序有保障,信号的编号越小优先级越高
应用程序中使用实时信号需要满足:
- 发送进程使用sigqueue()系统调用发送
- 接收实时信号的进程需要建立信号处理函数,使用sigaction并加入SA_SIGINFO,即使用sa_sigaction指针指向的处理函数。
sigqueue()原型如下:
#include <signal.h>int sigqueue(pid_t pid, int sig, const union sigval value);
函数参数和返回值含义如下:
- pid:指定接收信号的进程对应的pid,将信号发送给该进程。
- sig:指定需要发送的信号。与kill()函数一样,也可将参数sig设置为0,用于检查参数pid所指定的进程是否存在。
- value:指定了信号的伴随数据,union sigval数据类型。
- 返回值:成功返回0;失败将返回-1,并设置errno。
异常退出abort()
异常退出程序,一般使用abort()库函数,使用abort()终止进程运行,会生成核心转储文件,可用于判断程序调用abort()时的程序状态。原型如下:
#include <stdlib.h>void abort(void);
函数abort()通常产生SIGABRT信号来终止调用该函数的进程,SIGABRT信号的系统默认操作是终止进程运行、并生成核心转储文件;当调用abort()函数之后,内核会向进程发送SIGABRT信号。
进程
进程与程序
main()函数调用
操作系统下的应用程序在运行main()函数之前需要先执行一段引导代码,最终由这段引导代码去调用应用程序的main()函数,在编译链接时,由链接器将引导代码链接到我们的应用程序当中,一起构成最终的可执行文件。程序运行需要通过操作系统的加载器来实现,加载器是操作系统中的程序,当执行程序时,加载器负责将此应用程序加载内存中去执行。
在执行传参时,命令行参数会由shell进程逐一解析,shell进程会将参数传递给加载器,加载器加载应用程序时会将其传递给应用程序引导代码,当引导程序调用main()函数时,在由它最终传递给main()函数。
程序结束
大体可分为正常终止和异常终止,正常终止包括:
- main()函数中通过return语句返回来终止进程;
- 应用程序中调用exit()函数终止进程;
- 应用程序中调用_exit()或_Exit()终止进程;
异常终止包括:
- 应用程序中调用abort()函数终止进程;
- 进程接收到一个信号,譬如SIGKILL信号。
注册进程终止处理函数atexit(),是个库函数,用于注册一个进程在正常终止时要调用的函数,其函数原型如下所示:
#include <stdlib.h>int atexit(void (*function)(void));
函数参数和返回值含义如下:
- function:函数指针,指向注册的函数,此函数无需传入参数、无返回值。
- 返回值:成功返回0;失败返回非0。
进程
进程其实就是一个可执行程序的实例。
进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。
进程号
Linux系统下的每一个进程都有一个进程号(processID,简称PID),在Ubuntu下执行ps命令可查看系统中进程相关信息。进程号的作用就是用于唯一标识系统中某一个进程,在某些系统调用中,进程号可以作为传入参数、有时也可作为返回值。
可通过系统调用getpid()来获取本进程的进程号,其函数原型如下所示:
#include <sys/types.h>
#include <unistd.h>pid_t getpid(void);
函数返回值为pid_t类型变量,便是对应的进程号。
还可以使用getppid()系统调用获取父进程的进程号,其函数原型如下所示:
#include <sys/types.h>
#include <unistd.h>pid_t getppid(void);
返回值对应的便是父进程的进程号。
进程中环境变量
每一个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表中,把这个数组称为环境列表。其中每个字符串都是以“名称=值(name=value)”形式定义,所以环境变量是“名称-值”的成对集合。shell终端下可以用env命令查看shell进程的所有环境变量。
使用export命令可添加或删除环境变量(export -n就可以删除)。
应用程序中获取环境变量
进程的环境变量是从其父进程中继承过来的,应用程序中通过environ变量指向环境变量的字符串数组,environ是全局变量,只需申明即可使用:
extern char **environ; // 申明外部全局变量 environ
如果只想要获取某个指定的环境变量,可以使用库函数getenv(),其函数原型如下所示:
#include <stdlib.h>char *getenv(const char *name);
函数参数和返回值含义如下:
- name:指定获取的环境变量名称。
- 返回值:如果存放该环境变量,则返回该环境变量的值对应字符串的指针;如果不存在该环境变量,则返回NULL。
添加/删除/修改环境变量
putenv()函数可向进程的环境变量数组中添加一个新的环境变量,或者修改一个已经存在的环境变量对应的值,其函数原型如下所示:
#include <stdlib.h>int putenv(char *string);
函数参数和返回值含义如下:
- string:参数string是一个字符串指针,指向name=value形式的字符串。
- 返回值:成功返回0;失败将返回非0值,并设置errno。
setenv()函数可以替代putenv()函数,用于向进程的环境变量列表中添加一个新的环境变量或修改现有环境变量对应的值,其函数原型如下所示:
#include <stdlib.h>int setenv(const char *name, const char *value, int overwrite);
函数参数和返回值含义如下:
- name:需要添加或修改的环境变量名称。
- value:环境变量的值。
- overwrite:若参数name标识的环境变量已经存在,在参数overwrite为0的情况下,setenv()函数将不改变现有环境变量的值;如果参数overwrite的值为非0,若参数name标识的环境变量已经存在,则覆盖,不存在则表示添加新的环境变量。
- 返回值:成功返回0;失败将返回-1,并设置errno。
unsetenv()函数可以从环境变量表中移除参数name标识的环境变量,其函数原型如下所示:
#include <stdlib.h>int unsetenv(const char *name);
清空环境变量
可以通过将全局变量environ赋值为NULL
来清空所有变量:
environ = NULL;
也可通过clearenv()函数来操作,函数原型如下所示:
#include <stdlib.h>int clearenv(void);
环境变量作用
环境变量常见的用途之一是在shell中,每一个环境变量都有它所表示的含义,譬如HOME环境变量表示用户的家目录,USER环境变量表示当前用户名,SHELL环境变量表示 shell 解析器名称,PWD环境变量表示当前所在目录等,在自己的应用程序当中,也可以使用进程的环境变量。
进程内存布局
- 正文段。也可称为代码段,这是CPU执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。
- 初始化数据段。通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。
- 未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为bss段,在程序开始执行之前,系统会将本段内所有内存初始化为0,可执行文件并没有为bss段变量分配存储空间,在可执行文件中只需记录bss段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。
- 栈。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。
- 堆。可在运行时动态进行内存分配的一块区域,譬如使用malloc()分配的内存空间,就是从系统堆内存中申请分配的。
内存的典型布局方式如下所示:
进程的虚拟地址空间
在Linux系统中,每一个进程都在自己独立的
地址空间中运行,在32位系统中,每个进程的逻辑地址空间均为4GB,这4GB的内存空间按照3:1的比例进行分配,其中用户进程享有3G的空间,而内核独自享有剩下的1G空间。
虚拟地址会通过硬件MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU会将物理地址“翻译”为对应的物理地址。
程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上。所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来。
fork()创建子进程
一个现有的进程可以调用系统调用fork()函数创建一个新的进程,调用fork()函数的进程称为父进程,由fork()函数创建出来的进程被称为子进程(child process),fork()函数原型如下所示(fork()为系统调用):
#include <unistd.h>pid_t fork(void);
在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统
的并发性。
fork()调用成功后,将会在父进程中返回子进程的PID,而在子进程中返回值是0;如果调用失败,父进程返回值-1,不创建子进程,并设置errno。子进程是父进程的一个副本,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。
调用了fork()之后,父、子进程中一般只有一个会通过调用exit()退出进程,而另一个则应使用_exit()退出。
父进程、子进程会各自继续执行fork()之后的指令,它们共享代码段,但并不共享数据段、堆、栈等,而是子进程拥有父进程数据段、堆、栈等副本。
父子进程的文件共享
子进程拷贝了父进程的文件描述符表,使得父、子进程中对应的文件描述符指向了相同的文件表,也意味着父、子进程中对应的文件描述符指向了磁盘中相同的文件,因而这些文件在父、子进程间实现了共享。
父进程open打开后fork()创建子进程,子进程继承了文件描述符fd,不管是父进程,还是子进程,在每次写入时都是从文件的末尾写入,很像使用了O_APPEND标志的效果。
父进程调用fork()后,父、子进程分别open打开文件。这种文件共享方式实现的是一种两个进程分别各自对文件进行写入操作,因为父、子进程的这两个文件描述符分别指向的是不同的文件表,意味着它们有各自的文件偏移量,一个进程修改了文件偏移量并不会影响另一个进程的文件偏移量,所以写入的数据会出现覆盖的情况。
fork()应用场景:父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段;一个进程要执行不同的程序。
系统调用vfork()
Linux系统还提供了vfork()系统调用用于创建子进程,vfork()与fork()函数在功能上是相同的,并且返回值也相同,在一些细节上存在区别,vfork()函数原型如下所示:
#include <sys/types.h>
#include <unistd.h>pid_t vfork(void);
vfork()效率要高于fork()函数,vfork()可以为调用该函数的进程创建一个新的子进程,然而,vfork()是为子进程立即执行exec()新的程序而专门设计的。
vfork()可能会导致一些难以察觉的程序bug,所以尽量避免使用vfork()来创建子进程,除非对速度要求很高,否则使用fork()就可以了。
fork()之后的竞争条件
调用fork之后,无法确定父、子两个进程谁将率先访问CPU,也就是说无法确认谁先被系统调用运行(在多核处理器中,它们可能会同时各自访问一个CPU),这将导致谁先运行、谁后运行这个顺序是不确定的。
此时就可以通过同步技术,比如信号,例如需要让子进程先运行,则可使父进程阻塞,等待子进程来唤醒。
进程的诞生和终止
进程的诞生
Linux系统下的所有进程都是由其父进程创建而来,可通过“ps -aux”命令查看系统下所有进程。PID为1的就是所有进程的父进程,通常为init进程,是Linux系统启动之后运行的第一个进程,它管理着系统上所有其它进程,init进程是由内核启动,因此理论上说它没有父进程。
进程的终止
通常,进程有两种终止方式:异常终止和正常终止。
_exit()函数和exit()函数的status参数定义了进程的终止状态(termination status),父进程可以调用wait()函数以获取该状态。虽然参数status定义为int类型,但仅有低8位表示它的终止状态,一般来说,终止状态为0表示进程成功终止,而非0值则表示进程在执行过程中出现了一些错误而终止。
一般都会用库函数exit()来终止:
- 程序中注册了进程终止处理函数,那么会调用终止处理函数。
- 刷新stdio流缓冲区。
- 执行_exit()系统调用。
如果采用exit(),可能会导致重复输出,解决方法如下:
- 行缓冲设备,可加上对应换行符;
- 调用fork()前,使用函数fflush()刷新stdio缓冲区;
- 子进程调用_exit()退出进程。
监视子进程
父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息,是正常终止、还是异常终止亦或者被信号终止等,意味着父进程会对子进程进行监视。
wait()函数
系统调用wait()可以等待进程的任一子进程终止,同时获取子进程的终止状态信息,其函数原型如下所示:
#include <sys/types.h>
#include <sys/wait.h>pid_t wait(int *status);
函数参数和返回值含义如下:
- status:参数status用于存放子进程终止时的状态信息,参数status可以为NULL,表示不接收子进程终止时的状态信息。
- 返回值:若成功则返回终止的子进程对应的进程号;失败则返回-1。
waitpid()函数
wait()有很多限制:
- 如果父进程创建了多个子进程,使用wait()将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止;
- 如果子进程没有终止,正在运行,那么wait()总是保持阻塞;
- 使用wait()只能发现那些被终止的子进程。
waitpid()系统调用则可以突破这些限制,原型如下:
#include <sys/types.h>
#include <sys/wait.h>pid_t waitpid(pid_t pid, int *status, int options);
函数参数和返回值含义如下:
- pid:参数pid用于表示需要等待的某个具体子进程。
- options:稍后介绍。
- 返回值:返回值与wait()函数的返回值意义基本相同,在参数options包含了WNOHANG标志的情况下,返回值会出现0,稍后介绍。
option是一个位掩码,可通过标志位来组合多种情况。
waitid()函数
waitid()系统调用,与wait()和waitpid()类似,但是有更多扩展功能,这里不再赘述,可以自行通过man手册查询。
僵尸进程与孤儿进程
孤儿进程
父进程先于子进程结束,在Linux系统当中,所有的孤儿进程都自动成为init进程(进程号为1)的子进程。当然如果是在Ubuntu中测试,由于系统图像化界面,所以父进程会变成/sbin/upstart进程,其为后台守护进程专门收养孤儿进程。
僵尸进程
进程结束之后,通常需要其父进程回收子进程占用的一些内存资源,父进程通过调用wait()(或其变体 waitpid()、waitid()等)函数回收子进程资源,归还给系统。当子进程结束而父进程尚未回收时,子进程就会称为僵尸进程,当父进程调用wait()僵尸进程就会被内核删除;如果父进程直接退出了,此时init()进程会接管此子进程并自动调用wait()。
这里要注意,僵尸进程无法用SIGKILL将其杀死,只能通过杀死僵尸进程的父进程,使得init进程接管僵尸进程进而wait()删除。
SIGCHLD信号
以下两种情况会使得父进程接收该信号:
- 父进程的某个子进程终止;
- 父进程的某个子进程因收到信号而停止(暂停)或恢复。
子进程的终止属于异步事件,所以可以通过这个信号来让父进程捕获子进程终止并处理回收。为了防止两个子进程相继终止,而调用信号处理函数一次只能处理一个SIGCHLD信号,在这里信号处理函数中的循环以非阻塞方式来调用waitpid(),直到没有其他终止的子进程,代码如下:
while (waitpid(-1, NULL, WNOHANG) > 0)continue;
执行新程序
当子进程的工作不再是运行父进程的代码段,而是运行另一个新程序的代码,那么这个时候子进程可以通过exec函数来实现运行另一个新的程序。
execve()函数
系统调用execve()可以将新程序加载到某一进程的内存空间,通过调用execve()函数将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的main()函数开始执行。原型如下:
#include <unistd.h>int execve(const char *filename, char *const argv[], char *const envp[]);
函数参数和返回值含义如下:
- filename:参数filename指向需要载入当前进程空间的新程序的路径名,既可以是绝对路径、也可以是相对路径。
- argv:参数argv指定了传递给新程序的命令行参数。是一个字符串数组,该数组对应于 main(int argc, char *argv[])函数的第二个参数 argv,且格式也与之相同,是由字符串指针所组成的数组,以NULL结束。argv[0]对应的便是新程序自身路径名。
- envp:参数envp也是一个字符串指针数组,指定了新程序的环境变量列表,参数envp其实对应于新程序的environ数组,同样也是以NULL结束,所指向的字符串格式为name=value。
- 返回值:execve调用成功将不会返回;失败将返回-1,并设置errno。
exec库函数
这些库函数都是基于系统调用execve()而实现的,虽然参数各异、但功能相同,包括:execl()、execlp()、execle()、execv()、execvp()、execvpe(),它们的函数原型如下所示:
#include <unistd.h>extern char **environ;int execl(const char *path, const char *arg, ... /* (char *) NULL */);
int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
他们之间的区别如下:
- execl()和execv()都是基本的exec函数,都可用于执行一个新程序,它们之间的区别在于参数格式不同。execv()的argv与execve()一样,而execl()则是依次排列的可变参数形式。
- execlp()和execvp()在execl()和execv()基础上加了一个p,这个p其实表示的是PATH;execl()和execv()要求提供新程序的路径名,而execlp()和execvp()则允许只提供新程序文件名,系统会在由环境变量PATH所指定的目录列表中寻找相应的可执行文件(常用语执行Linux命令)。
- execle()和execvpe()这两个函数在命名上加了一个e,这个e其实表示的是environment 环境变量,意味着这两个函数可以指定自定义的环境变量列表给新程序。
system()函数
使用system()函数可以很方便地程序当中执行任意shell命令,原型如下:
#include <stdlib.h>int system(const char *command);
函数参数和返回值含义如下:
- command:参数command指向需要执行的shell命令,以字符串的形式提供,譬如"ls -al"、"echo HelloWorld"等。
- 返回值:有多种不同的情况,稍后介绍。
system()函数其内部的是通过调用fork()、execl()以及waitpid()这三个函数来实现它的功能,首先system()会调用fork()创建一个子进程来运行shell(可以把这个子进程成为shell进程),并通过shell执行参数command所指定的命令。
返回值如下:
- 当参数command为NULL,如果shell可用则返回一个非0值,若不可用则返回0;针对一些非UNIX系统,该系统上可能是没有shell的,这样就会导致shell不可能;如果command参数不为NULL,则返回值从以下的各种情况所决定。
- 如果无法创建子进程或无法获取子进程的终止状态,那么system()返回-1;
- 如果子进程不能执行shell,则system()的返回值就好像是子进程通过调用_exit(127)终止了;
- 如果所有的系统调用都成功,system()函数会返回执行command的shell进程的终止状态。
进程状态与进程关系
进程状态
Linux系统下进程通常存在6种不同的状态,分为:就绪态、运行态、僵尸态、可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)以及暂停态。
- 就绪态(Ready):指该进程满足被CPU调度的所有条件但此时并没有被调度执行,只要得到CPU就能够直接运行;意味着该进程已经准备好被CPU执行,当一个进程的时间片到达,操作系统调度程序会从就绪态链表中调度一个进程;
- 运行态:指该进程当前正在被CPU调度运行,处于就绪态的进程得到CPU调度就会进入运行态;
- 僵尸态:僵尸态进程其实指的就是僵尸进程,指该进程已经结束、但其父进程还未给它“收尸”;
- 可中断睡眠状态:可中断睡眠也称为浅度睡眠,还可以被唤醒,一般来说可以通过信号来唤醒;
- 不可中断睡眠状态:不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件成立才能结束睡眠状态。把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态;所以,处于等待态的进程是无法参与进程系统调度的。
- 暂停态:暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如SIGSTOP信号;处于暂停态的进程是可以恢复进入到就绪态的,譬如收到SIGCONT信号。
各状态切换关系总结如下:
进程关系
主要包括:无关系(相互独立)、父子进程关系、进程组以及会话。
1、无关系
2、父子进程关系
例如一个进程fork()之后,就是父子进程关系。
3、进程组
每个进程除了有一个进程ID、父进程ID之外,还有一个进程组ID,用于标识该进程属于哪一个进程组,进程组是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系。
- 每个进程必定属于某一个进程组、且只能属于一个进程组;
- 每一个进程组有一个组长进程,组长进程的ID就等于进程组ID;
- 在组长进程的ID前面加上一个负号即是操作进程组;
- 组长进程不能再创建新的进程组;
- 只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关;
- 一个进程组可以包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组;
- 默认情况下,新创建的进程会继承父进程的进程组ID。
通过系统调用getpgrp()或getpgid()可以获取进程对应的进程组ID,其函数原型如下所示:
#include <unistd.h>pid_t getpgid(pid_t pid);
pid_t getpgrp(void);
getpgrp()没有参数,返回值总是调用者进程对应的进程组ID;而对于getpgid()函数来说,可通过参数pid指定获取对应进程的进程组 ID,如果参数pid为0表示获取调用者进程的进程组ID。getpgid()函数成功将返回进程组ID;失败将返回-1、并设置errno。
调用系统调用setpgid()或setpgrp()可以加入一个现有的进程组或创建一个新的进程组,其函数原型如下所示:
#include <unistd.h>int setpgid(pid_t pid, pid_t pgid);
int setpgrp(void);
setpgid()函数将参数pid指定的进程的进程组ID设置为参数gpid。如果这两个参数相等(pid==gpid),则由pid指定的进程变成为进程组的组长进程,创建了一个新的进程;如果参数pid等于0,则使用调用者的进程ID;另外,如果参数gpid等于0,则创建一个新的进程组,由参数pid指定的进程作为进程组组长进程。
4、会话
一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它的是后台进程组;每个会话都有一个会话首领(leader),即创建会话的进程。一个会话可以有控制终端、也可没有控制终端,在有控制终端的情况下也只能连接一个控制终端,这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(譬如通过SSH协议网络登录),一个会话中的进程组可被分为一个前台进程组以及一个或多个后台进程组。
会话的首领进程连接一个终端之后,该终端就成为会话的控制终端,与控制终端建立连接的会话首领进程被称为控制进程;产生在终端上的输入和信号将发送给会话的前台进程组中的所有进程。在Linux系统下打开了多个终端窗口时,实际上就是创建了多个终端会话。
会话的首领进程的进程组ID将作为该会话的标识,也就是会话ID(sid),在默认情况下,新创建的进程会继承父进程的会话ID。通过系统调用getsid()可以获取进程的会话ID,其函数原型如下所示:
#include <unistd.h>pid_t getsid(pid_t pid);
使用系统调用setsid()可以创建一个会话,其函数原型如下所示:
#include <unistd.h>pid_t setsid(void);
如果调用者进程不是进程组的组长进程,调用setsid()将创建一个新的会话,调用者进程是新会话的首领进程,同样也是一个新的进程组的组长进程,调用setsid()创建的会话将没有控制终端。setsid()调用成功将返回新会话的会话ID;失败将返回-1,并设置errno。
守护进程
概念
守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生,主要表现为以下两个特点:长期运行以及与控制终端脱离。
守护进程是一种很有用的进程。Linux中大多数服务器就是用守护进程实现的,譬如,Internet服务器inetd、Web服务器httpd等。同时,守护进程完成许多系统任务,譬如作业规划进程crond等。
守护进程Daemon,通常简称为d,一般进程名后面带有d就表示它是一个守护进程,用户的登录与注销与守护进程无关、不受其影响,守护进程自成进程组、自成会话,即pid=gid=sid。可用“ps -ajx”查看系统所有进程。
编写守护进程程序
1、创建子进程、终止父进程
父进程调用fork()创建子进程,然后父进程使用exit()退出。
2、子进程调用setsid创建会话
关键步骤,setsid函数能够使子进程完全独立出来,从而脱离所有其他进程的控制。
3、工作目录改为根目录
4、重设文件权限掩码umask
设置文件权限掩码的函数是umask,通常的使用方法为umask(0)。
5、关闭不需要的文件描述符
6、将文件描述符0、1、2定位到/dev/null
7、其他:忽略SIGCHLD信号
SIGHUP信号
当用户准备退出会话时,系统向该会话发出SIGHUP信号,会话将SIGHUP信号发送给所有子进程,子进程接收到SIGHUP信号后,便会自动终止,当所有会话中的所有进程都退出时,会话也就终止了。
当程序当中忽略SIGHUP信号之后,进程不会随着终端退出而退出。
单例模式运行
程序只能被执行一次,只要该程序没有结束,就无法再次运行,把这种情况称为单例模式运行。
通过文件存在于否判断
这种方法比较好想到,但是对于程序异常终止,以及删除特定的文件无法正常运行。
使用文件锁
通过一个特定的文件来实现,当程序启动之后,首先打开该文件,调用open时一般使用O_WRONLY | O_CREAT标志,当文件不存在则创建该文件,然后尝试去获取文件锁,若是成功,则将程序的进程号(PID)写入到该文件中,写入后不要关闭文件或解锁(释放文件锁),保证进程一直持有该文件锁;若是程序获取锁失败,代表程序已经被运行、则退出本次启动。程序退出或关闭文件,文件锁自动解锁。
通过系统调用flock()、fcntl()或库函数lockf()均可实现对文件进行上锁。
这种机制在一些程序尤其是服务器程序中很常见,服务器程序使用这种方法来保证程序的单例模式运行;在Linux系统中/var/run/目录下有很多以.pid为后缀结尾的文件,这个实际上是为了保证程序以单例模式运行而设计的,作为程序实现单例模式运行所需的特定文件。
进程间通信简介
不同的进程都在各自的地址空间中、相互独立、隔离,所以它们是处在于不同的地址空间中,因此相互通信比较难,Linux内核提供了多种进程间通信的机制。
进程间通信简介
进程间通信(interprocess communication,简称IPC)指两个进程之间的通信。对于一些复杂、大型的应用程序,则会根据实际需要将其设计成多进程程序,譬如GUI、服务区应用程序等。
通信机制
Linux内核提供了多种IPC机制,基本是从UNIX系统继承而来。
其中,早期的UNIX IPC包括:管道、FIFO、信号;System V IPC包括:System V信号量、System V消息队列、System V共享内存;上图中还出现了POSIX IPC,事实上,较早的System V IPC存在着一些不足之处,而POSIX IPC则是在System V IPC的基础上进行改进所形成的,弥补了System V IPC的一些不足之处。POSIX IPC包括:POSIX信号量、POSIX消息队列、POSIX共享内存。
线程
本章学习Linux应用编程中非常重要的编程技巧——线程(Thread);与进程类似,线程是允许应用程序并发执行多个任务的一种机制,线程参与系统调度,事实上,系统调度的最小单元是线程、而并非进程。
线程概述
线程概念
线程是参与系统调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。一个线程指的是进程中一个单一顺序的控制流(或者说是执行路线、执行流),一个进程中可以创建多个线程,多个线程实现并发运行,每个线程执行不同的任务。
当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread),因为它是程序一开始时就运行的线程。应用程序都是以main()做为入口开始运行的,所以 main()函数就是主线程的入口函数,main()函数所执行的任务就是主线程需要执行的任务。
线程是程序最基本的运行单位,而进程不能运行,真正运行的是进程中的线程。同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack,称为线程栈),自己的寄存器环境(register context)、自己的线程本地存储(thread-local storage)。
在多线程应用程序中,通常一个进程中包括了多个线程,每个线程都可以参与系统调度、被CPU执行:线程是包含在进程中的,不会单独存在;线程是参与系统调度的基本单位;可并发执行;共享进程资源。
相对于多进程,多线程能够有如下优势:同一进程的多个线程切换开销较小;同一进程的多个线程通信容易;线程创建速度远大于进程创建速度;多线程在多核处理器上更有优势!
并发与并行
并行与串行则截然不同,并行指的是可以并排/并列执行多个任务,这样的系统,它通常有多个执行单元,所以可以实现并行运行。并行运行并不一定要同时开始运行、同时结束运行,只需满足在某一个时间段上存在多个任务被多个执行单元同时在运行着。
并发强调的是一种时分复用,与串行的区别在于,它不必等待上一个任务完成之后在做下一个任务,可以打断当前执行的任务切换执行下一个任何,这就是时分复用。在同一个执行单元上,将时间分解成不同的片段(时间片),每个任务执行一段时间,时间一到则切换执行下一个任务,依次这样轮询(交叉/交替执行),这就是并发运行。
需要注意的是,并行运行情况下的多个执行单元,每一个执行单元同样也可以以并发方式运行。
线程ID
每个线程也有其对应的标识,称为线程ID。进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。
而线程ID使用pthread_t数据类型来表示,一个线程可通过库函数pthread_self()来获取自己的线程ID,其函数原型如下所示:
#include <pthread.h>pthread_t pthread_self(void);
该函数调用总是成功,返回当前线程的线程ID。
可以使用pthread_equal()函数来检查两个线程ID是否相等,其函数原型如下所示:
#include <pthread.h>int pthread_equal(pthread_t t1, pthread_t t2);
如果两个线程ID t1和t2相等,则pthread_equal()返回一个非零值;否则返回0。
在Linux系统中,使用无符号长整型(unsigned long int)来表示pthread_t数据类型。
创建线程
主线程可以使用库函数pthread_create()负责创建一个新的线程,创建出来的新线程被称为主线程的子线程,其函数原型如下所示:
#include <pthread.h>int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
函数参数和返回值含义如下:
- thread:pthread_t类型指针,当pthread_create()成功返回时,新创建的线程的线程ID会保存在参数thread所指向的内存中。
- attr:pthread_attr_t类型指针,指向pthread_attr_t类型的缓冲区,pthread_attr_t数据类型定义了线程的各种属性。如果将参数attr设置为NULL,那么表示将线程的所有属性设置为默认值,以此创建新线程。
- start_routine:是一个函数指针,指向一个函数,新创建的线程从start_routine()函数开始运行,该函数返回值类型为void *,并且该函数的参数只有一个void *,其实这个参数就是pthread_create()函数的第四个参数arg。如果需要向start_routine()传递的参数有一个以上,那么需要把这些参数放到一个结构体中,然后把这个结构体对象的地址作为arg参数传入。
- arg:传递给start_routine()函数的参数。一般情况下,需要将arg指向一个全局或堆变量,意思就是说在线程的生命周期中,该arg指向的对象必须存在,否则如果线程中访问了该对象将会出现错误。当然也可将参数arg设置为NULL,表示不需要传入参数给start_routine()函数。
- 返回值:成功返回0;失败时将返回一个错误号,并且参数thread指向的内容是不确定的。
使用时,通过gcc -o编译时,需要在最后指定链接库,-lpthread。
终止线程
终止线程除了在线程start函数中执行return语句,还有以下:
- 线程调用pthread_exit()函数;
- 调用pthread_cancel()取消线程。
如果进程中的任意线程调用exit()、_exit()或者_Exit(),那么将会导致整个进程终止,这里需要注意!pthread_exit()函数将终止调用它的线程,其函数原型如下所示:
#include <pthread.h>void pthread_exit(void *retval);
参数retval的数据类型为void *,指定了线程的返回值、也就是线程的退出码,该返回值可由另一个线程通过调用pthread_join()来获取;参数retval所指向的内容不应分配于线程栈中。
回收线程
通过调用pthread_join()函数来阻塞等待线程的终止,并获取线程的退出码,回收线程资源;函数原型如下所示:
#include <pthread.h>int pthread_join(pthread_t thread, void **retval);
函数参数和返回值含义如下:
- thread:pthread_join()等待指定线程的终止,通过参数thread(线程ID)指定需要等待的线程;
- retval:如果参数retval不为NULL,则pthread_join()将目标线程的退出状态(即目标线程通过pthread_exit()退出时指定的返回值或者在线程start函数中执行return语句对应的返回值)复制到*retval所指向的内存区域;如果目标线程被pthread_cancel()取消,则将PTHREAD_CANCELED放在*retval 中。如果对目标线程的终止状态不感兴趣,则可将参数 retval设置为NULL。
- 返回值:成功返回0;失败将返回错误码。
不能以非阻塞的方式调用pthread_join(),且进程中任意线程均可调用pthread_join()函数等待另一个线程的终止。
取消线程
在程序设计需求当中,需要向一个线程发送一个请求,要求它立刻退出,把这种操作称为取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。
取消一个线程
通过调用pthread_cancel()库函数向一个指定的线程发送取消请求,其函数原型如下所示:
#include <pthread.h>int pthread_cancel(pthread_t thread);
发出取消请求之后,函数pthread_cancel()立即返回,不会等待目标线程的退出。所以pthread_cancel()并不会等待线程终止,仅仅只是提出请求。
取消状态以及类型
默认情况下,线程是响应其它线程发送过来的取消请求的,响应请求然后退出线程。当然,线程可以选择不被取消或者控制如何被取消,通过pthread_setcancelstate()和pthread_setcanceltype()来设置线程的取消性状态和类型。原型如下:
#include <pthread.h>int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);
pthread_setcancelstate()函数会将调用线程的取消性状态设置为参数state中给定的值,并将线程之前的取消性状态保存在参数oldstate指向的缓冲区中,如果对之前的状态不感兴趣,Linux允许将参数oldstate设置为NULL;pthread_setcancelstate()调用成功将返回 0,失败返回非0值的错误码。
如果线程的取消性状态为PTHREAD_CANCEL_ENABLE,那么对取消请求的处理则取决于线程的取消性类型,该类型可以通过调用pthread_setcanceltype()函数来设置,它的参数type指定了需要设置的类型,而线程之前的取消性类型则会保存在参数oldtype所指向的缓冲区中,如果对之前的类型不敢兴趣,Linux下允许将参数oldtype设置为NULL。同样pthread_setcanceltype()函数调用成功将返回0,失败返回非0值的错误码。
type必须为以下二选一:
- PTHREAD_CANCEL_DEFERRED:取消请求到来时,线程还是继续运行,取消请求被挂起,直到线程到达某个取消点(cancellation point)为止,这是所有新建线程包括主线程默认的取消性类型。
- PTHREAD_CANCEL_ASYNCHRONOUS:可能会在任何时间点取消线程,这种取消性类型应用场景很少,不再介绍!
取消点
所谓取消点其实就是一系列函数,当执行到这些函数的时候,才会真正响应取消请
求,这些函数就是取消点。
可通过man手册查询取消点,命令为“man 7 pthreads”。
线程可取消性检测
假设线程执行的是一个不含取消点的循环(譬如for循环、while循环),那么这时线程永远也不会响应取消请求,也就意味着除了线程自己主动退出,其它线程将无法通过向它发送取消请求而终止它。此时,可使用pthread_testcancel(),可产生一个取消点,线程即可随之终结,原型如下:
#include <pthread.h>void pthread_testcancel(void);
分离线程
默认情况下,当线程终止时,其它线程可以通过调用pthread_join()获取其返回状态、回收线程资源。如果仅需线程终止时自动回收资源并移除,可以调用pthread_detach()将指定线程进行分离,也就是分离线程,原型如下所示:
#include <pthread.h>int pthread_detach(pthread_t thread);
参数thread指定需要分离的线程,函数pthread_detach()调用成功将返回0;失败将返回一个错误码。
一个线程也可以将自己分离,处于分离状态就不能使用pthread_join()获取终止状态,且不可逆。不过终止后,可自动回收线程资源。
注册线程清理处理函数
当线程终止退出时,去执行这样的类似进程终止处理函数,把这个称为线程清理函数(thread cleanup handler)。
与进程不同,一个线程可以注册多个清理函数,这些清理函数记录在栈中,每个线程都可以拥有一个清理函数栈。
线程通过函数pthread_cleanup_push()和pthread_cleanup_pop()分别负责向调用线程的清理函数栈中添加和移除清理函数,函数原型如下所示:
#include <pthread.h>void pthread_cleanup_push(void (*routine)(void *), void *arg);
void pthread_cleanup_pop(int execute);
第一个参数routine是一个函数指针,指向一个需要添加的清理函数,routine()函数无返回值,只有一个void *类型参数;第二个参数arg,当调用清理函数routine()时,将arg作为routine()函数的参数。
pthread_cleanup_pop()的execute参数,如果为 0,清理函数不会被调用,只是将清理函数栈中最顶层的函数移除;如果为非0,则除了将清理函数栈中最顶层的函数移除之外,还会该清理函数。
当线程执行以下动作时,清理函数栈中的清理函数才会被执行:
- 线程调用pthread_exit()退出时;
- 线程响应取消请求时;
- 用非0参数调用pthread_cleanup_pop()。
线程属性
在Linux下,使用pthread_attr_t数据类型定义线程的所有属性。
调用pthread_create()创建线程时,参数attr设置为NULL,表示使用属性的默认值创建线程。如果不使用默认值,参数attr必须要指向一个pthread_attr_t对象,而不能使用NULL。当定义pthread_attr_t对象之后 ,需要使用pthread_attr_init()函数对该对象进行初始化操作 ,当对象不再使用时,需要使用pthread_attr_destroy()函数将其销毁,函数原型如下所示:
#include <pthread.h>int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);
在调用成功时返回0,失败将返回一个非0值的错误码。
比较关注属性包括:线程栈的位置和大小、线程调度策略和优先级,以及线程的分离状态属性等。
线程栈属性
pthread_attr_t数据结构中定义了栈的起始地址以及栈大小,调用函数pthread_attr_getstack()可以获取这些信息,函数pthread_attr_setstack()对栈起始地址和栈大小进行设置,其函数原型如下所示:
#include <pthread.h>int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
int pthread_attr_getstack(const pthread_attr_t *attr, void **stackaddr, size_t *stacksize);
函数pthread_attr_getstack(),参数和返回值含义如下:
- attr:参数attr指向线程属性对象。
- stackaddr:调用pthread_attr_getstack()可获取栈起始地址,并将起始地址信息保存在*stackaddr中;
- stacksize:调用pthread_attr_getstack()可获取栈大小,并将栈大小信息保存在参数stacksize所指向的内存中;
- 返回值:成功返回0,失败将返回一个非0值的错误码。
函数pthread_attr_setstack(),参数和返回值含义如下:
- attr:参数attr指向线程属性对象。
- stackaddr:设置栈起始地址为指定值。
- stacksize:设置栈大小为指定值;
- 返回值:成功返回0,失败将返回一个非0值的错误码。
如果想单独获取或设置栈大小、栈起始地址,可以使用下面这些函数:
#include <pthread.h>int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
int pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize);
int pthread_attr_setstackaddr(pthread_attr_t *attr, void *stackaddr);
int pthread_attr_getstackaddr(const pthread_attr_t *attr, void **stackaddr);
分离状态属性
如果在创建线程时就确定要将该线程分离,可以修改pthread_attr_t结构中的detachstate线程属性,让线程一开始运行就处于分离状态。调用函数pthread_attr_setdetachstate()设置detachstate线程属性,调用pthread_attr_getdetachstate()获取detachstate线程属性,其函数原型如下所示:
#include <pthread.h>int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);
参数attr指向pthread_attr_t对象;调用pthread_attr_setdetachstate()函数将detachstate线程属性设置为参数detachstate所指定的值。函数pthread_attr_getdetachstate()用于获取 detachstate 线程属性,将detachstate线程属性保存在参数detachstate所指定的内存中。
线程安全
线程栈
每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰。
可重入函数
如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。
在多线程环境以及信号处理有关应用程序中,需要注意不可重入函数的问题,如果多条执行流同时调用一个不可重入函数则可能会得不到预期的结果、甚至有可能导致程序崩溃!不止是在应用程序中,在一个包含了中断处理的裸机应用程序中亦是如此!所以不可重入函数通常存在着一定的安全隐患。
很多的C库函数有两个版本:可重入版本和不可重入版本,可重入版本函数其名称后面加上了“_r”,用于表明该函数是一个可重入函数。可通过man手册查询。
线程安全函数
一个函数被多个线程(其实也是多个执行流,但是不包括由信号处理函数所产生的执行流)同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数。线程安全函数包括可重入函数,可重入函数是线程安全函数的真子集,即可重入函数一定是线程安全函数。
判断一个函数是否为线程安全函数的方法是,该函数被多个线程同时调用是否总能产生正确的结果,如果每次都能产生预期的结果则表示该函数是一个线程安全函数。
一次性初始化
在多线程编程环境下,有些代码段只需要执行一次,就可使用pthread_once()实现,原型如下:
#include <pthread.h>pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
函数参数和返回值含义如下:
- once_control:这是一个pthread_once_t类型指针,在调用pthread_once()函数之前,需要定义了一个pthread_once_t类型的静态变量,调用pthread_once()时参数once_control指向该变量。通常在定义变量时会使用PTHREAD_ONCE_INIT宏对其进行初始化。
- init_routine:一个函数指针,参数init_routine所指向的函数就是要求只能被执行一次的代码段,pthread_once()函数内部会调用init_routine(),即使pthread_once()函数会被多次执行,但它能保证init_routine()仅被执行一次。
- 返回值:调用成功返回0;失败则返回错误编码以指示错误原因。
线程特有数据
线程特有数据也称为线程私有数据,简单点说,就是为每个调用线程分别维护一份变量的副本(copy),每个线程通过特有数据键(key)访问时,这个特有数据键都会获取到本线程绑定的变量副本。这样就可以避免变量成为多个线程间的共享数据。
线程特有数据的核心思想,就是为每一个调用线程(调用某函数的线程,该函数就是要通过线程特有数据将其实现为线程安全的函数)分配属于该线程的私有数据区,为每个调用线程分别维护一份变量的副本。
在为线程分配私有数据区之前,需要调用pthread_key_create()函数创建一个特有数据键(key),并且只需要在首个调用的线程中创建一次即可,所以通常会使用到pthread_once()函数。pthread_key_create()函数原型如下所示:
#include <pthread.h>int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
函数参数和返回值含义如下:
- key:调用该函数会创建一个特有数据键,并通过参数key所指向的缓冲区返回给调用者,参数key是一个pthread_key_t类型的指针,可以把pthread_key_t称为key类型。调用pthread_key_create()之前,需要定义一个pthread_key_t类型变量,调用pthread_key_create()时参数key指向pthread_key_t类型变量。
- destructor:参数destructor是一个函数指针,指向一个自定义的函数。该函数通常用于释放与特有数据键关联的线程私有数据区占用的内存空间,当使用线程特有数据的线程终止时,destructor()函数会被自动调用。
- 返回值:成功返回0;失败将返回一个错误编号以指示错误原因,返回的错误编号其实就是全局变量errno,可以使用诸如strerror()函数查看其错误字符串信息。
调用pthread_key_create()函数创建特有数据键(key)后通常需要为调用线程分配私有数据缓冲区,譬如通过malloc()申请堆内存,每个调用线程分配一次,且只会在线程初次调用此函数时分配。为线程分配私有数据缓冲区之后,通常需要调用pthread_setspecific()函数,pthread_setspecific()函数其实完成
了这样的操作:首先保存指向线程私有数据缓冲区的指针,并将其与特有数据键以及当前调用线程关联起来;其函数原型如下所示:
#include <pthread.h>int pthread_setspecific(pthread_key_t key, const void *value);
函数参数和返回值含义如下:
- key:pthread_key_t类型变量,参数key应赋值为调用pthread_key_create()函数时创建的特有数据键。
- value:参数value是一个void类型的指针,指向由调用者分配的一块内存,作为线程的私有数据缓冲区,当线程终止时,会自动调用参数key指定的特有数据键对应的解构函数来释放这一块动态申请的内存空间。
- 返回值:调用成功返回0;失败将返回一个错误编码,可以使用诸如strerror()函数查看其错误字符串信息。
调用pthread_setspecific()函数将线程私有数据缓冲区与调用线程以及特有数据键关联之后,便可以使用pthread_getspecific()函数来获取调用线程的私有数据区了。其函数原型如下所示:
#include <pthread.h>void *pthread_getspecific(pthread_key_t key);
参数key应赋值为调用pthread_key_create()函数时创建的特有数据键,也就是pthread_key_create()函数的参数key指向的pthread_key_t变量。
如果需要删除一个特有数据键(key)可以使用函数 thread_key_delete(),
thread_key_delete()函数删除先前由pthread_key_create()创建的键。其函数原型如下所示:
#include <pthread.h>int pthread_key_delete(pthread_key_t key);
参数key为要删除的键。函数调用成功返回0,失败将返回一个错误编号。
通常在调用pthread_key_delete()之
前,必须确保以下条件:
- 所有线程已经释放了私有数据区(显式调用解构函数或线程终止)。
- 参数key指定的特有数据键将不再使用。
线程局部存储
通常情况下,程序中定义的全局变量是进程中所有线程共享的,所有线程都可以访问这些全局变量;而线程局部存储在定义全局或静态变量时,使用__thread修饰符修饰变量,此时,每个线程都会拥有一份对该变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储。
关于线程局部变量的声明和使用,需要注意以下几点:
- 如果变量声明中使用了关键字static或extern,那么关键字__thread必须紧随其后。
- 与一般的全局或静态变量申明一眼,线程局部变量在申明时可设置一个初始值。
- 可以使用C语言取值操作符(&)来获取线程局部变量的地址。
更多细节问题
主要包括线程与信号之间牵扯的问题、线程与进程控制(fork()、exec()、exit()等)之间的交互。
线程与信号
信号既要能够在传统的单线程进程中保持它原有的功能、特性,与此同时,又需要设计出能够适用于多线程环境的新特性!
信号模型在一些方面是属于进程层面(由进程中的所有线程线程共享)的,而在另一些方面是属于单个线程层面的,以下对其进行汇总:
- 信号的系统默认行为是属于进程层面。
- 信号处理函数属于进程层面。
- 信号的发送既可针对整个进程,也可针对某个线程。
- 当一个多线程进程接收到一个信号时,且该信号绑定了信号处理函数时,内核会任选一个线程来接收这个信号。
- 信号掩码其实是属于线程层面的,也就是说信号掩码是针对每个线程而言。
- 针对整个进程所挂起的信号,以及针对每个线程所挂起的信号,内核都会分别进行维护、记录。
在多线程环境下,使用pthread_sigmask()函数来设置各个线程的信号掩码,其函数原型如下所示:
#include <signal.h>int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);
pthread_sigmask()函数就像sigprocmask()一样,不同之处在于它在多线程程序中使用,所以pthread_sigmask()函数的用法与sigprocmask()完全一样。
在多线程程序中,可以通过pthread_kill()向同一进程中的某个指定线程发送信号,其函数原型如下所示:
#include <signal.h>int pthread_kill(pthread_t thread, int sig);
参数thread,也就是线程ID,用于指定同一进程中的某个线程,调用pthread_kill()将向参数thread指定的线程发送信号sig。如果参数sig为0,则不发送信号,但仍会执行错误检查。函数调用成功返回0,失败将返回一个错误编号,不会发送信号。
pthread_sigqueue()函数执行与sigqueue类似的任务,但它不是向进程发送信号,而是向同一进程中的某个指定的线程发送信号。其函数原型如下所示:
#include <signal.h>
#include <pthread.h>int pthread_sigqueue(pthread_t thread, int sig, const union sigval value);
参数thread为线程ID,指定接收信号的目标线程(目标线程与调用pthread_sigqueue()函数的线程是属于同一个进程),参数sig指定要发送的信号,参数value指定伴随数据,与sigqueue()函数中的value参数意义相同。
异步信号安全函数指的是可以在信号处理函数中可以被安全调用的线程安全函数,所以它比线程安全函数的要求更为严格!可重入函数满足这个要求,所以可重入函数一定是异步信号安全函数。而线程安全函数则不一定是异步信号安全函数了。
线程同步
这里在linux驱动的时候已经有过涉及了,这里就不过多展开,大概记一下API。
使用原因
线程同步是为了对共享资源的访问进行保护。保护的目的是为了解决数据一致性的问题。出现数据一致性问题其本质在于进程中的多个线程对共享资源的并发访问(同时访问)。
互斥锁
互斥锁使用pthread_mutex_t数据类型表示,在使用互斥锁之前,必须首先对它进行初始化操作,可以使用两种方式对互斥锁进行初始化操作。
互斥锁初始化
使用PTHREAD_MUTEX_INITIALIZER宏初始化互斥锁。只适用于在定义的时候就直接进行初始化。
可以使用pthread_mutex_init()函数对互斥锁进行初始化,其函数原型如下所示:
#include <pthread.h>int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
函数参数和返回值含义如下:
- mutex:参数mutex是一个pthread_mutex_t类型指针,指向需要进行初始化操作的互斥锁对象;
- attr:参数attr是一个pthread_mutexattr_t类型指针,指向一个pthread_mutexattr_t类型对象,该对象用于定义互斥锁的属性,若将参数attr设置为NULL,则表示将互斥锁的属性设置为默认值,在这种情况下其实就等价于PTHREAD_MUTEX_INITIALIZER这种方式初始化,而不同之处在于,使用宏不进行错误检查。
- 返回值:成功返回0;失败将返回一个非0的错误码。
互斥锁加锁与解锁
互斥锁初始化之后,处于一个未锁定状态,调用函数 thread_mutex_lock()可以对互斥锁加锁、获取互斥锁,而调用函数pthread_mutex_unlock()可以对互斥锁解锁、释放互斥锁。其函数原型如下所示:
#include <pthread.h>int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数mutex指向互斥锁对象;pthread_mutex_lock()和pthread_mutex_unlock()在调用成功时返回0;失败将返回一个非0值的错误码。
pthread_mutex_trylock()函数
当互斥锁已经被其它线程锁住时,调用pthread_mutex_lock()函数会被阻塞,直到互斥锁解锁;如果线程不希望被阻塞,可以使用pthread_mutex_trylock()函数;调用pthread_mutex_trylock()函数尝试对互斥锁进行加锁,如果互斥锁处于未锁住状态,那么调用pthread_mutex_trylock()将会锁住互斥锁并立马返回,如果互斥锁已经被其它线程锁住,调用pthread_mutex_trylock()加锁失败,但不会阻塞,而是返回错误码EBUSY。原型如下:
#include <pthread.h>int pthread_mutex_trylock(pthread_mutex_t *mutex);
参数mutex指向目标互斥锁,成功返回0,失败返回一个非0值的错误码,如果目标互斥锁已经被其它线程锁住,则调用失败返回EBUSY。
销毁互斥锁
当不再需要互斥锁时,应该将其销毁,通过调用pthread_mutex_destroy()函数来销毁互斥锁,其函数原型如下所示:
#include <pthread.h>int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数mutex指向目标互斥锁;同样在调用成功情况下返回0,失败返回一个非0值的错误码。
互斥锁死锁
当超过一个线程对同一组互斥锁(两个或两个以上的互斥锁)进行加锁时,就有可能发生死锁。两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,会被一直阻塞。
互斥锁属性
调用pthread_mutex_init()函数初始化互斥锁时可以设置互斥锁的属性,通过参数attr指定。如果不使用默认属性,在调用pthread_mutex_init()函数时,参数attr必须要指向一个pthread_mutexattr_t对象,而不能使用NULL。当定义pthread_mutexattr_t对象之后,需要使用pthread_mutexattr_init()函数对该对象进行初始化操作,当对象不再使用时,需要使用pthread_mutexattr_destroy()将其销毁,函数原型如下所示:
#include <pthread.h>int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
参数attr指向需要进行初始化的pthread_mutexattr_t对象,调用成功返回0,失败将返回非0值的错误码。
可以使用pthread_mutexattr_gettype()函数得到互斥锁的类型属性,使用pthread_mutexattr_settype()修改/设置互斥锁类型属性,其函数原型如下所示:
#include <pthread.h>int pthread_mutexattr_gettype(const pthread_mutexattr_t *attr, int *type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
参数attr指向pthread_mutexattr_t类型对象;对于pthread_mutexattr_gettype()函数,函数调用成功会将互斥锁类型属性保存在参数type所指向的内存中,通过它返回出来;而对于pthread_mutexattr_settype()函数,会将参数attr指向的pthread_mutexattr_t对象的类型属性设置为参数type指定的类型。
条件变量
条件变量是线程可用的另一种同步机制。条件变量用于自动阻塞线程,直到某个特定事件发生或某个条件满足为止,通常情况下,条件变量是和互斥锁一起搭配使用的。使用条件变量主要包括两个动作:
- 一个线程等待某个条件满足而被阻塞;
- 另一个线程中,条件满足时发出“信号”。
条件变量允许一个线程休眠(阻塞等待)直至获取到另一个线程的通知(收到信号)再去执行自己的操作。
条件变量初始化
条件变量使用pthread_cond_t数据类型来表示,类似于互斥锁,在使用条件变量之前必须对其进行初始化。初始化方式同样也有两种:使用宏PTHREAD_COND_INITIALIZER或者使用函数pthread_cond_init(),使用宏的初始化方法与互斥锁的初始化宏一样。
pthread_cond_init()函数原型如下所示:
#include <pthread.h>int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
使用pthread_cond_init()函数初始化条件变量,当不再使用时,使用pthread_cond_destroy()销毁条件变量。参数 cond 指向 pthread_cond_t 条件变量对象,对于pthread_cond_init()函数,类似于互斥锁,在初始化条件变量时设置条件变量的属性,参数attr指向一个pthread_condattr_t类型对象pthread_condattr_t数据类型用于描述条件变量的属性。可将参数attr设置为NULL,表示使用属性的默认值来初始化条件变量,与使用PTHREAD_COND_INITIALIZER宏相同。函数调用成功返回0,失败将返回一个非0值的错误码。
通知/等待条件变量
发送信号操作即是通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变,这些处于等待状态的线程收到通知之后便会被唤醒,唤醒之后再检查条件是否满足。等待操作是指在收到一个通知前一直处于阻塞状态。
函数pthread_cond_signal()和pthread_cond_broadcast()均可向指定的条件变量发送信号,通知一个或多个处于等待状态的线程。调用pthread_cond_wait()函数是线程阻塞,直到收到条件变量的通知。
pthread_cond_signal()和pthread_cond_broadcast()函数原型如下所示:
#include <pthread.h>int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
参数cond指向目标条件变量,向该条件变量发送信号。调用成功返回0;失败将返回一个非0值的错误码。
两者区别在于:对阻塞于pthread_cond_wait()的多个线程对应的处理方式不同,pthread_cond_signal()函数至少能唤醒一个线程,而pthread_cond_broadcast()函数则能唤醒所有线程。使用pthread_cond_broadcast()函数总能产生正确的结果,唤醒所有等待状态的线程,但函数pthread_cond_signal()会更为高效,因为它只需确保至少唤醒一个线程即可。
pthread_cond_wait()函数原型如下所示:
#include <pthread.h>int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
函数参数与返回值意义如下:
- cond:指向需要等待的条件变量,目标条件变量;
- mutex:参数mutex是一个pthread_mutex_t类型指针,指向一个互斥锁对象;条件变量通常是和互斥锁一起使用,因为条件的检测(条件检测通常是需要访问共享资源的)是在互斥锁的保护下进行的,也就是说条件本身是由互斥锁保护的。
- 返回值:调用成功返回0;失败将返回一个非0值的错误码。
在pthread_cond_wait()函数内部会对参数mutex所指定的互斥锁进行操作,通常情况下,条件判断以及pthread_cond_wait()函数调用均在互斥锁的保护下,也就是说,在此之前线程已经对互斥锁加锁了。条件变量并不保存状态信息,只是传递应用程序状态信息的一种通讯机制。
条件变量的判断条件
使用条件变量,都会有与之相关的判断条件,通常情况下,会涉及到一个或多个共享变量。必须使用while循环,而不是if语句,这是一种通用的设计原则:当线程从pthread_cond_wait()返回时,并不能确定判断条件的状态,应该立即重新检查判断条件,如果条件不满足,那就继续休眠等待。
条件变量的属性
调用pthread_cond_init()函数初始化条件变量时,可以设置条件变量的属性,通过参数attr指定。参数attr指向一个pthread_condattr_t类型对象,该对象对条件变量的属性进行定义,当然,如果将参数attr设置为NULL,表示使用默认值来初始化条件变量属性。条件变量包括两个属性:进程共享属性和时钟属性。
自旋锁
如果在获取自旋锁时,自旋锁处于未锁定状态,那么将立即获得锁(对自旋锁上锁);如果在获取自旋锁时,自旋锁已经处于锁定状态了,那么获取锁操作将会在原地“自旋”,直到该自旋锁的持有者释放了锁。
自旋锁一直占用的CPU,它在未获得锁的情况下,一直处于运行状态(自旋),所以占着CPU,如果不能在很短的时间内获取锁,这无疑会使CPU效率降低。
自旋锁通常用于以下情况:需要保护的代码段执行时间很短,这样就会使得持有锁的线程会很快释放锁,而“自旋”等待的线程也只需等待很短的时间;在这种情况下就比较适合使用自旋锁,效率高!
自旋锁初始化
自旋锁使用pthread_spinlock_t数据类型表示,当定义自旋锁后,需要使用pthread_spin_init()函数对其进行初始化,当不再使用自旋锁时,调用pthread_spin_destroy()函数将其销毁,其函数原型如下所示:
#include <pthread.h>int pthread_spin_destroy(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
参数lock指向了需要进行初始化或销毁的自旋锁对象,参数pshared表示自旋锁的进程共享属性。这两个函数在调用成功的情况下返回0;失败将返回一个非0值的错误码。
自旋锁加锁/解锁
可以使用pthread_spin_lock()函数或pthread_spin_trylock()函数对自旋锁进行加锁,前者在未获取到锁时一直“自旋”;对于后者,如果未能获取到锁,就立刻返回错误,错误码为EBUSY。不管以何种方式加锁,
自旋锁都可以使用pthread_spin_unlock()函数对自旋锁进行解锁。其函数原型如下所示:
#include <pthread.h>int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
参数lock指向自旋锁对象,调用成功返回0,失败将返回一个非0值的错误码。
读写锁
读写锁有3种状态:读模式下的加锁状态(简称读加锁状态)、写模式下的加锁状态(以下简称写加锁状态)和不加锁状态(见),一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁。因此可知,读写锁比互斥锁具有更高的并行性!
读写锁有如下两个规则:
- 当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行加锁操作(不管是以读模式加锁还是以写模式加锁)的线程都会被阻塞。
- 当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功;但是任何以写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止。
读写锁非常适合于对共享数据读的次数远大于写的次数的情况。读写锁也叫做共享互斥锁。当读写锁是读模式锁住时,就可以说成是共享模式锁住。当它是写模式锁住时,就可以说成是互斥模式锁住。
读写锁初始化
与互斥锁、自旋锁类似,在使用读写锁之前也必须对读写锁进行初始化操作,读写锁使用pthread_rwlock_t数据类型表示,读写锁的初始化可以使用宏PTHREAD_RWLOCK_INITIALIZER或者函数pthread_rwlock_init(),其初始化方式与互斥锁相同。
可以使用pthread_rwlock_init()函数对其进行初始化,当读写锁不再使用时,需要调用pthread_rwlock_destroy()函数将其销毁,其函数原型如下所示:
#include <pthread.h>int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
参数rwlock指向需要进行初始化或销毁的读写锁对象。对于pthread_rwlock_init()函数,参数attr是一
个pthread_rwlockattr_t *类型指针,指向pthread_rwlockattr_t对象。pthread_rwlockattr_t 数据类型定义了读写锁的属性,若将参数attr设置为NULL,则表示将读写锁的属性设置为默认值,在这种情况下其实就等价于PTHREAD_RWLOCK_INITIALIZER这种方式初始化,而不同之处在于,使用宏不进行错误检查。调用成功返回0,失败将返回一个非0值的错误码。
当读写锁不再使用时,需要调用pthread_rwlock_destroy()函数将其销毁。
读写锁上锁/解锁
以读模式对读写锁进行上锁,需要调用pthread_rwlock_rdlock()函数;以写模式对读写锁进行上锁,需要调用pthread_rwlock_wrlock()函数。不管是以何种方式锁住读写锁,均可以调用pthread_rwlock_unlock()函数解锁,其函数原型如下所示:
#include <pthread.h>int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
参数rwlock指向读写锁对象。调用成功返回0,失败返回一个非0值的错误码。
当读写锁处于写模式加锁状态时,其它线程调用pthread_rwlock_rdlock()或pthread_rwlock_wrlock()函数均会获取锁失败,从而陷入阻塞等待状态;当读写锁处于读模式加锁状态时,其它线程调用pthread_rwlock_rdlock()函数可以成功获取到锁,如果调用pthread_rwlock_wrlock()函数则不能获取到锁,从而陷入阻塞等待状态。
如果线程不希望被阻塞,可以调用pthread_rwlock_tryrdlock()和pthread_rwlock_trywrlock()来尝试加锁。
如果不可以获取锁时,这两个函数都会立马返回错误,错误码为EBUSY。其函数原型如下所示:
#include <pthread.h>int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
读写锁的属性
读写锁的属性使用pthread_rwlockattr_t数据类型来表示,当定义pthread_rwlockattr_t对象时,需要使用pthread_rwlockattr_init()函数对其进行初始化操作,初始化会将pthread_rwlockattr_t对象定义的各个读写锁属性初始化为默认值;当不再使用pthread_rwlockattr_t对象时,需要调用pthread_rwlockattr_destroy()函数将其销毁,其函数原型如下所示:
#include <pthread.h>int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
参数attr指向需要进行初始化或销毁的pthread_rwlockattr_t对象;函数调用成功返回0,失败将返回一个非0值的错误码。
读写锁只有一个属性,那便是进程共享属性。Linux下提供了相应的函数用于设置或获取读写锁的共享属性。函数pthread_rwlockattr_getpshared()用于从pthread_rwlockattr_t对象中获取共享属性,函数pthread_rwlockattr_setpshared()用于设置pthread_rwlockattr_t对象中的共享属性,其函数原型如下所示:
#include <pthread.h>int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *attr, int *pshared);
int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr, int pshared)
函数pthread_rwlockattr_getpshared()参数和返回值:
- attr:指向pthread_rwlockattr_t对象;
- pshared:调用pthread_rwlockattr_getpshared()获取共享属性,将其保存在参数pshared所指向的内存中;
- 返回值:成功返回0,失败将返回一个非0值的错误码。
函数pthread_rwlockattr_setpshared()参数和返回值:
- attr:指向pthread_rwlockattr_t对象;
- pshared:调用pthread_rwlockattr_setpshared()设置读写锁的共享属性,将其设置为参数pshared指定的值。
- 返回值:调用成功的情况下返回0;失败将返回一个非0值的错误码。
总结
本章介绍了线程同步的几种不同的方法,包括互斥锁、条件变量、自旋锁以及读写锁。在实际应用开发当中,用的最多的还是互斥锁和条件变量。
高级I/O
主要包括:非阻塞I/O、I/O多路复用、异步I/O、存储映射I/O以及文件锁。
非阻塞I/O
阻塞其实就是进入了休眠状态,交出了CPU控制权。非阻塞式I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误!
阻塞I/O与非阻塞I/O读文件
在调用open()函数打开文件时,为参数flags指定O_NONBLOCK标志,open()调用成功后,后续的I/O操作将以非阻塞式方式进行。
这里可通过读取鼠标信息来学习两者区别。鼠标是输入设备,在/dev/input中,可通过“ls -lh”查看,并通过"sudo od -x /dev/input/eventX"来确定具体的文件设备。
如果采用非阻塞IO,那么open的时候要加上O_NONBLOCK,并且read需要在一个死循环中,从而完成轮询读取。
阻塞I/O的优缺点
阻塞式I/O的优点在于能够提升CPU的处理效率,当自身条件不满足时,进入阻塞状态,交出CPU资源,将CPU资源让给别人使用;而非阻塞式则是抓紧利用CPU资源,譬如不断地去轮询,这样就会导致该程序占用了非常高的CPU使用率!
使用非阻塞I/O实现并发读取
如果是读取键盘输入,他的标准输入设备是stdin,进程会自动继承,标准输入设备的fd为0,可直接使用而不用open。
阻塞就会导致无法并发读取数据(同时读取),当然可以通过多线程或父子进程来完成,但通过非阻塞IO会更简单。当然,此时就会因为轮询导致CPU占用率高。
I/O多路复用
I/O多路复用定义
I/O多路复用(IO multiplexing)通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行I/O操作时,能够通知应用程序进行相应的读写操作。I/O多路复用技术是为了解决:在并发式I/O场景中进程或线程阻塞到某个I/O系统调用而出现的技术,使进程不阻塞于某个特定的I/O系统调用。
可以采用两个功能几乎相同的系统调用来执行I/O多路复用操作,分别是系统调用select()和poll()。
I/O多路复用存在一个非常明显的特征:外部阻塞式,内部监视多路I/O。
select()函数
系统调用select()可用于执行I/O多路复用操作,调用select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)。其函数原型如下所示:
#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其中参数readfds、writefds以及exceptfds都是fd_set类型指针,指向一个fd_set类型对象,fd_set数据类型是一个文件描述符的集合体,所以参数readfds、writefds以及exceptfds都是指向文件描述符集合的指针。
fd_set 数据类型是以位掩码的形式来实现的,Linux提供了四个宏用于对fd_set类型对象进行操作,FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()。
参数nfds通常表示最大文件描述符编号值加1,考虑readfds、writefds以及exceptfds这三个文件描述符集合,在3个描述符集中找出最大描述符编号值,然后加1。
timeout可用于设定select()阻塞的时间上限,控制select的阻塞行为,可将timeout参数设置为NULL,表示select()将会一直阻塞、直到某一个或多个文件描述符成为就绪态;也可将其指向一个struct timeval结构体对象。
四个宏定义:
#include <sys/select.h>void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
文件描述符集合有一个最大容量限制,有常量FD_SETSIZE来决定,在Linux系统下,该常量的值为1024。在定义一个文件描述符集合之后,必须用FD_ZERO()宏将其进行初始化操作,然后再向集合中添加关心的各个文件描述符。
返回值:
- 返回-1表示有错误发生,并且会设置errno。
- 返回0表示在任何文件描述符成为就绪态之前select()调用已经超时,在这种情况下,readfds,writefds以及exceptfds所指向的文件描述符集合都会被清空。
- 返回一个正整数表示有一个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述符的个数,在这种情况下,每个返回的文件描述符集合都需要检查,通过FD_ISSET()宏进行检查,以此找出发生的I/O事件是什么。如果同一个文件描述符在readfds,writefds以及 exceptfds中同时被指定,且它多于多个I/O事件都处于就绪态的话,那么就会被统计多次,换句话说,select()返回三个集合中被标记为就绪态的文件描述符的总数。
poll()函数
系统调用poll()与select()函数很相似,但函数接口有所不同。需要构造一个struct pollfd类型的数组,每个数组元素指定一个文件描述符以及我们对该文件描述符所关心的条件(数据可读、可写或异常情况)。poll()函数原型如下所示:
#include <poll.h>int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数参数含义如下:
- fds:指向一个struct pollfd类型的数组,数组中的每个元素都会指定一个文件描述符以及对该文件描述符所关心的条件。
- nfds:参数nfds指定了fds数组中的元素个数,数据类型nfds_t实际为无符号整形。
- timeout:该参数与select()函数的timeout参数相似,用于决定poll()函数的阻塞行为,具体用法如下:
- 如果timeout等于-1,则poll()会一直阻塞,直到fds数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号时返回。
- 如果timeout等于0,poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态。
- 如果timeout大于0,则表示设置poll()函数阻塞时间的上限值,意味着poll()函数最多阻塞timeout毫秒,直到fds数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号为止。
struct pollfd结构体如下所示:
struct pollfd {int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */
};
fd是一个文件描述符,struct pollfd结构体中的events和revents都是位掩码,初始化events来指定需要为文件描述符fd做检查的事件。当poll()函数返回时,revents变量由poll()函数内部进行设置,用于说明文件描述符fd发生了哪些事件(poll()没有更改events变量)。
poll()返回值与select()一样。
总结
在使用select()或poll()时需要注意一个问题,当监测到某一个或多个文件描述符成为就绪态(可以读或写)时,需要执行相应的I/O操作,以清除该状态,否则该状态将会一直存在。
异步I/O
在异步I/O中,当文件描述符上可以执行I/O操作时,进程可以请求内核为自己发送一个信号。之后进程就可以执行任何其它的任务直到文件描述符可以执行I/O操作为止,此时内核会发送信号给进程。
使用步骤如下:
- 通过指定O_NONBLOCK标志使能非阻塞I/O。
- 通过指定O_ASYNC标志使能异步I/O。
- 设置异步I/O事件的接收进程。也就是当文件描述符上可执行I/O操作时会发送信号通知该进程,通常将调用进程设置为异步I/O事件的接收进程。
- 为内核发送的通知信号注册一个信号处理函数。默认情况下,异步I/O的通知信号是SIGIO,所以内核会给进程发送信号SIGIO。
- 以上步骤完成之后,进程就可以执行其它任务了,当I/O操作就绪时,内核会向进程发送一个SIGIO信号,当进程接收到信号时,会执行预先注册好的信号处理函数,就可以在信号处理函数中进行I/O操作。
O_ASYNC标志
O_ASYNC标志可用于使能文件描述符的异步I/O事件,该标志主要用于异步I/O。调用open()时无法通过指定O_ASYNC标志来使能异步I/O,但可以使用fcntl()函数添加O_ASYNC标志使能异步I/O。
设置并注册SIGIO信号处理函数
通过fcntl()函数进行设置,操作命令cmd设置为F_SETOWN,第三个参数传入接收进程的进程ID(PID),通常将调用进程的PID传入。
通过signal()或sigaction()函数为SIGIO信号注册一个信号处理函数,当进程接收到内核发送过来的SIGIO信号时,会执行该处理函数,在处理函数当中执行相应的I/O操作。
优化异步I/O
在一个需要同时检查大量文件描述符的应用程序中,例如某种类型的网络服务端程序,与select()和poll()相比,异步I/O能够提供显著的性能优势。
使用实时信号替换SIGIO
SIGIO作为异步I/O通知的默认信号,是一个非实时信号,可以设置不使用默认信号,指定一个实时信号作为异步I/O通知信号。只要调用fcntl()函数,第3个参数arg指定实时信号编号即可。
使用sigaction()函数注册信号处理函数
使用sigaction函数进行注册,并为sa_flags参数指定SA_SIGINFO,表示使用sa_sigaction指向的函数作为信号处理函数,而不使用sa_handler指向的函数。
函数参数中包括一个siginfo_t指针,指向siginfo_t类型对象,当触发信号时该对象由内核构建。对于异步I/O,传递给信号处理函数的siginfo_t结构体有如下相关字段:
- si_signo:引发处理函数被调用的信号。这个值与信号处理函数的第一个参数一致。
- si_fd:表示发生异步I/O事件的文件描述符;
- si_code:表示文件描述符si_fd发生了什么事件,读就绪态、写就绪态或者是异常事件等。
- si_band:是一个位掩码,其中包含的值与系统调用poll()中返回的revents字段中的值相同。
存储映射I/O
存储映射I/O(memory-mapped I/O)是一种基于内存区域的高级I/O操作,它能将一个文件映射到进程地址空间中的一块内存区域中,当从这段内存中读数据时,就相当于读文件中的数据(对文件进行read操作),将数据写入这段内存时,则相当于将数据直接写入文件中(对文件进行write操作)。
mmap()和mummap()函数
为了实现存储映射I/O这一功能,需要告诉内核将一个给定的文件映射到进程地址空间中的一块内存区域中,这由系统调用mmap()来实现。其函数原型如下所示:
#include <sys/mman.h>void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
函数参数和返回值含义如下:
- addr:参数addr用于指定映射到内存区域的起始地址。通常将其设置为NULL,这表示由系统选择该映射区的起始地址,这是最常见的设置方式;如果参数addr不为NULL,则表示由自己指定映射区的起始地址,此函数的返回值是该映射区的起始地址。
- length:参数length指定映射长度,表示将文件中的多大部分映射到内存区域中,以字节为单位。
- offset:文件映射的偏移量,通常将其设置为0,表示从文件头部开始映射;所以参数offset和参数length就确定了文件的起始位置和长度,将文件的这部分映射到内存区域中。
- fd:文件描述符,指定要映射到内存区域中的文件。
- prot:参数prot指定了映射区的保护要求。
- flags:参数flags可影响映射区的多种属性。通常情况下只指定MAP_SHARED。
- 返回值:成功情况下,函数的返回值便是映射区的起始地址;发生错误时,返回(void *)-1,通常使用MAP_FAILED来表示,并且会设置errno来指示错误原因。
参数addr和offset在不为NULL和0的情况下,addr和offset的值通常被要求是系统页大小的整数倍,可通过sysconf()函数获取页大小,以字节为单位(sysconf(_SC_PAGE_SIZE))。
相关信号:
- SIGSEGV:如果映射区被mmap()指定成了只读的,那么进程试图将数据写入到该映射区时,将会产生SIGSEGV信号。
- SIGBUS:如果映射区的某个部分在访问时已不存在,则会产生SIGBUS信号。
通过mmap()将文件映射到进程地址空间中
的一块内存区域中,当不再需要时,必须解除映射,使用munmap()解除映射关系,其函数原型如下所示:
#include <sys/mman.h>int munmap(void *addr, size_t length);
参数addr指定待解除映射地址范围的起始地址,它必须是系统页大小的整数倍;参数length是一个非负整数,指定了待解除映射区域的大小(字节数),被解除映射的区域对应的大小也必须是系统页大小的整数倍,length并不一定为页大小整数倍。
当进程终止时也会自动解除映射(如果程序中没有显式调用munmap()),但调用close()关闭文件时并不会解除映射。
mprotect()函数
使用系统调用mprotect()可以更改一个现有映射区的保护要求,其函数原型如下所示:
#include <sys/mman.h>int mprotect(void *addr, size_t len, int prot);
参数prot的取值与mmap()函数的prot参数的一样,mprotect()函数会将指定地址范围的保护要求更改为参数prot所指定的类型,参数addr指定该地址范围的起始地址,addr的值必须是系统页大小的整数倍;参数len指定该地址范围的大小。mprotect()函数调用成功返回0;失败将返回-1,并且会设置errno来只是错误原因。
msync()函数
可以调用msync()函数将映射区中的数据刷写、更新至磁盘文件中(同步操作),系统调用msync()类似fsync()函数,不过msync()作用于映射区。该函数原型如下所示:
#include <sys/mman.h>int msync(void *addr, size_t length, int flags);
参数addr和length指定了需同步的内存区域的起始地址和大小。对于参数addr来说,同样也要求必须是系统页大小的整数倍,也就是与系统页大小对齐。参数flags应指定为MS_ASYNC和MS_SYNC两个标志之一,除此之外,还可以根据需求选择是否指定MS_INVALIDATE标志,作为一个可选标志。
msync()函数在调用成功情况下返回0;失败将返回-1、并设置errno。
普通I/O与存储映射I/O比较
普通I/O方式一般是通过调用read()和write()函数来实现对文件的读写,使用read()和write()读写文件时,函数经过层层的调用后,才能够最终操作到文件,中间涉及到很多的函数调用过程,效率会比较低。
存储映射I/O的实质其实是共享,与IPC之内存共享很相似。由于源文件和目标文件都已映射到了应用层的内存区域中,所以直接操作映
射区来实现文件复制。其所映射的文件只能是固定大小,且映射的内存区域必须是系统页整数倍。使用存储映射I/O在进行大数据量操作时比较有效。
文件锁
文件锁,顾名思义是一种应用于文件的锁机制,当多个进程同时操作同一文件时,为了保证数据正确性,linux通常采用的方法是对文件上锁,来避免多个进程同时操作同一文件时产生竞争状态。
文件锁可分为建议性锁和强制性锁:
- 建议性锁本质上是一种协议,程序访问文件之前,先对文件上锁,上锁成功之后再访问文件,这是建议性锁的一种用法。
- 是一种强制性的要求,如果进程对文件上了强制性锁,其它的进程在没有获取到文件锁的情况下是无法对文件进行访问的。
flock()函数加锁
系统调用flock(),使用该函数可以对文件加锁或者解锁,但是flock()函数只能产生建议性锁,其函数原型如下所示:
#include <sys/file.h>int flock(int fd, int operation);
函数参数和返回值含义如下:
- fd:参数fd为文件描述符,指定需要加锁的文件。
- operation:参数operation指定了操作方式。
- 返回值:成功将返回0;失败返回-1、并会设置errno。
使用flock()的几个原则:
- 同一进程对文件多次加锁不会导致死锁。
- 文件关闭的时候,会自动解锁。
- 一个进程不可以对另一个进程持有的文件锁进行解锁。
- 由 fork()创建的子进程不会继承父进程所创建的锁。
当一个文件描述符被复制时(譬如使用 dup()、dup2()或fcntl()F_DUPFD操作),这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解锁都可以。
fcntl()函数
fcntl()函数是一个多功能文件描述符管理工具箱,通过配合不同的cmd操作命令来实现不同的功能。原型如下:
#include <unistd.h>
#include <fcntl.h>int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
与锁相关的cmd为F_SETLK、F_SETLKW、F_GETLK,第三个参数flockptr是一个struct flock结构体指针。
fcntl()与flock()有两个较大区别:
- flock()仅支持对整个文件进行加锁/解锁;而 fcntl()可以对文件的某个区域进行加锁/解锁,可以精确到某一个字节数据。
- flock()仅支持建议性锁类型;而fcntl()可支持建议性锁和强制性锁两种类型。
可以设置为F_RDLCK、F_WRLCK和F_UNLCK三种类型之一,F_RDLCK表示共享性质的读锁,F_WRLCK表示独占性质的写锁,F_UNLCK表示解锁一个区域。任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定的字节上只能有一个进程有一把独占写锁。
文件锁相关的三个cmd它们的作用:
- F_GETLK:这种用法一般用于测试,测试调用进程对文件加一把由参数flockptr指向的struct flock对象所描述的锁是否会加锁成功。如果加锁不成功,意味着该文件的这部分区域已经存在一把锁,并且由另一进程所持有,并且调用进程加的锁与现有锁之间存在排斥关系,现有锁会阻止调用进程想要加的锁,并且现有锁的信息将会重写参数flockptr指向的对象信息。如果不存在这种情况,也就是说flockptr指向的struct flock对象所描述的锁会加锁成功,则除了将struct flock对象的l_type修改为F_UNLCK之外,结构体中的其它信息保持不变。
- F_SETLK:对文件添加由flockptr指向的struct flock对象所描述的锁。
- F_SETLKW:此命令是F_SETLK的阻塞版本(命令名中的W表示等待wait),如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁,而导致请求失败,那么调用进程将会进入阻塞状态。只有当请求的锁可用时,进程才会被唤醒。
使用fcntl()的几个原则:
- 文件关闭的时候,会自动解锁。
- 一个进程不可以对另一个进程持有的文件锁进行解锁。
- 由 fork()创建的子进程不会继承父进程所创建的锁。
如果复制文件,与flock()一样,随便用哪个文件描述符都可以解锁。
建议性锁和强制性锁
一般不建议使用强制性锁,所以大部分情况下使用的都是建议性锁。
如果要开启强制性锁机制,需要设置文件的 Set-Group-ID(S_ISGID)位为 1,并且禁止文件的组用户执行权限(S_IXGRP),也就是将其设置为0。但是也有些Linux是不支持强制性锁机制的。
lockf()函数
lockf()函数是一个库函数,其内部是基于fcntl()来实现的,所以lockf()是对fcntl锁的一种封装。
小结
这一章主要是非阻塞I/O、I/O多路复用、异步I/O、存储映射I/O以及文件锁:
- 非阻塞I/O:进程向文件发起I/O操作,使其不会被阻塞。
- I/O多路复用:select()和poll()函数。
- 异步 I/O:当文件描述符上可以执行I/O操作时,内核会向进程发送信号通知它。
- 存储映射I/O:mmap()函数。
- 文件锁:flock()、fcntl()以及lockf()函数。
入门篇总结
- 第一章 应用编程概念:本章介绍了何为应用编程,与逻辑编程、驱动编程有什么区别。
- 第二章 文件I/O基础:文件I/O作为Linux最基本、最重要的编程技巧,必然要掌握于心!
- 第三章 深入探究文件I/O:本章深入了解了文件I/O中的一些细节,譬如文件的管理方式、错误返回的处理、空洞文件、O_APPEND和O_TRUNC标志、原子操作与竞争冒险等等。
- 第四章 标准I/O库:本章介绍了标准I/O库,使用标准I/O库函数对文件进行I/O操作、标准I/O库函数与普通I/O read()/write()之间的区别、标准I/O库的缓冲与文件I/O的内核缓冲等。
- 第五章 文件属性与目录:本章介绍了文件相关的特性以及相关属性,譬如文件类型、文件属性、文件访问权限、文件时间戳、符号链接与硬链接等。
- 第六章 字符串处理:字符串处理在几乎所有的编程语言中都是一个绕不开的话题,本章介绍了C库函数中提供的一些用于处理字符串相关的函数以及正则表达式。
- 第七章 系统信息与系统资源:本章介绍了用于获取系统信息相关的函数接口,譬如系统信息sysinfo()、系统时间日期、proc文件系统等,以及系统资源的使用,譬如申请堆内存。
- 第八章 信号:在很多应用程序当中,都会存在处理异步事件这种需求,而信号提供了一种处理异步事件的方法,本章介绍了Linux下的信号相关的内容,包括:信号的概念、信号的分类、进程对信号的处理、发送信号、信号掩码、实时信号等。
- 第九章 进程:本章介绍了进程相关的内容,包括:进程的概念、fork()创建子进程、父子进程间的文件共享、进程的诞生与终止、进程状态与进程关系、守护进程等。
- 第十章 进程间通信:本章内容介绍Linux下提供的进程间通信的手段,用于在多进程的环境下,在一些中小型的程序设计中,多进程的设计其实很少用到,主要用在一些大型项目中,本章以了解为主,在实际编程中需要用到再去深入学习即可!
- 第十一章 线程:本章介绍了线程相关的内容,多线程编程在实际的Linux应用项目中占了很大一部分,所以多线程是必须要掌握的一个编程技巧。
- 第十二章 线程同步:涉及到线程,那就必然绕不开线程同步,本章介绍了用于实现线程同步的几种不同的方式以及它们的原理,譬如互斥锁、条件变量、自旋锁以及读写锁等,不同的方式适用于不同的场景,需要根据应用场景来选择!
- 第十三章 高级 I/O:本章介绍了文件I/O当中的一些高级用法,包括:非阻塞I/O、I/O多路复用、异步 I/O、存储映射I/O以及文件锁。