文章目录
- 信号
- 进程
- 进程通信
- 线程
- 可/不可重入函数
- 线程同步
- 互斥锁
- 条件变量
- 自旋锁
- 读写锁
- I/O操作
- 阻塞/非阻塞I/O
- I/O多路复用
- 存储映射I/O
信号
信号是事件发生时对进程的通知机制,可以看做软件中断。信号与硬件中断的相似之处在于其能够打断程序当前执行的正常流程。大多数情况下无法预测信号达到的准确时间,所以信号提供了一种处理异步事件的方法。
信号的目的就是用来通信的,通过信号将情况告知相应的进程,一个具有合适权限的进程能够向另一个进程发送信号。
信号的产生条件包括:硬件发生异常、在终端下输入了能够产生信号的特殊字符(如CTRL+C产生中断信号SIGINT)、用户使用kill命令杀死进程、软件事件(比如定时器超时)等。
信号是异步的,产生信号的事件对进程而言是随机出现的,进程无法预测该事件产生的准确时间,进程不能够通过简单地测试一个变量或使用系统调用来判断是否产生了一个信号,这就如同硬件中断事件,程序是无法得知中断事件产生的具体时间,只有当产生中断事件时,才会告知程序、然后打断当前程序的正常执行流程、跳转去执行中断服务函数,这就是异步处理方式。
信号本质上是整型数字编号,相当于硬件中断所对应的中断号。内核针对每个信号,都给其定义了一个唯一的整数编号,从数字1开始顺序展开。
进程
操作系统下的应用程序在运行main()函数之前需要先执行一段引导代码,最终由这段引导代码去调用应用程序的main()函数。
进程是一个动态过程,而不是静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。
Linux系统下的每一个进程都有一个进程号(PID),进程号是一个正数,用于唯一标识系统中的某一个进程。在应用程序中,可通过系统调用getpid()来获取本进程的进程号,通过getppid()来获取父进程的进程号。
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); //获取本进程的进程号
pid_t getppid(void); //获取父进程的进程号
每一个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表中,把这个数组称为环境列表。export命令可以添加或删除一个环境变量。
env //查看环境变量
export LINUX_TEST=123456 //添加命令
export -n LINUX_TEST //删除命令
进程的环境变量是从其父进程中继承过来的,新的进程在创建之前,会继承其父进程的环境变量副本。
C语言程序由正文段、初始化数据段、未初始化数据段、栈、堆组成。
正文段也可称为代码段,这是CPU执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令,正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。
初始化数据段通常称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。
未初始化数据段包含了未进行显式初始化的全局变量和静态变量,通常将此段称为bss段,这一名词来源于早期汇编程序中的一个操作符,意思是由符号开始的块(block started by symbol)。在程序开始执行之前,系统会将本段内所有内存初始化为0,可执行文件并没有为bss段变量分配存储空间,在可执行文件中只需记录bss段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。
栈是函数内局部变量以及每次函数调用时所需保存信息的存放段,每次调用函数时,函数传递的实参以及函数返回值等都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量、实参和返回值。
堆是在程序运行时动态进行内存分配的一块区域,譬如使用malloc()分配的内存空间,就是从系统堆内存中申请分配的。
使用 size 可执行程序文件名 命令可以查看二进制可执行文件的文本段、数据段、bss段的段大小。
内存布局由下图所示。
在Linux系统中,采用了虚拟内存管理技术,每一个进程都在各自独立的地址空间中运行,在32位系统中,每个进程的逻辑地址空间均为4GB,这4GB的内存空间按照3:1的比例进行分配,其中用户进程享有3G的空间,而内核独自享有剩下的1G空间。
虚拟地址会通过硬件内存管理单元映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作。
引入虚拟地址将其与物理地址空间隔离开有很多优点。进程与进程、进程与内核相互隔离,提高了系统的安全性与稳定性。两个或者更多进程能够共享内存,共享内存可用于实现进程间通信。便于实现内存保护机制,编译应用程序时,无需关心链接地址,链接地址和运行地址必须一致才能使程序运行。
一个现有的进程可以调用fork()函数创建一个新的进程,原进程和创建出来的子进程都会从fork()函数的返回处继续执行,会导致调用fork()返回两次,可通过返回值来区分子进程和父进程。fork()调用成功后,将会在父进程中返回子进程的PID,而在子进程中返回值是0。调用失败,父进程返回值-1,不创建子进程。
子进程是父进程的一个副本,它拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行fork()之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。父子进程执行相同的代码段,在内存中只存在一份代码段数据。
下面代码就是父子进程在同一个文件中执行写操作。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <strings.h>
#include <error.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
pid_t pid;
int fd;
fd = open("./aaa", O_RDWR);
if(fd < 0)
{
perror("open error");
return fd;
}
pid = fork();
switch(pid)
{
case -1:
perror("fork failed");break;
case 0:
write(fd, "1234",4);break;
default:
write(fd, "abab",4);break;
}
close(fd);
return 0;
}
上面程序的执行结果是"abab1234",先是父进程写,然后是子进程写,父子进程实现了接续写,每次写都是从文件末尾开始写入。
这个例子也说明,子进程继承了父进程的文件描述符,两个文件描述符都指向了一个文件表,所以它们的文件偏移量是同一个,子进程改变文件的位置偏移量会作用到父进程,父进程改变文件的位置偏移量也会作用到子进程。
如果在父子进程中分别打开同一个文件进行写操作,就会出现数据被覆盖的情况。
进程号为1的进程是所有进程的父进程,通常称为init进程,它是Linux系统启动之后运行的第一个进程,它管理着系统上所有其它进程,init进程由内核启动,理论上说它没有父进程。
父进程先于子进程结束,该子进程将会成为孤儿进程,在Linux系统当中,所有的孤儿进程都自动成为init进程的子进程。
进程结束之后,通常需要其父进程回收子进程占用的一些内存资源,父进程通过调用wait()等函数回收子进程资源并归还给系统。
子进程先于父进程结束,而父进程还未来得及回收子进程占用的内存资源,此时子进程就变成了一个僵尸进程。当父进程调用wait()函数回收子进程资源后,僵尸进程就会被内核彻底删除。如果父进程并没有调用wait()函数就退出了,那么此时init进程将会接管它的子进程并自动调用wait(),僵尸进程将会被移除。
如果系统中存在大量的僵尸进程,会阻碍新进程的创建。而且僵尸进程是无法通过信号将其杀死的,这种情况下只能杀死僵尸进程的父进程或等待其父进程终止,这样init进程将会接管这些僵尸进程,将它们从系统中清理掉。
Linux系统下进程通常存在6种不同的状态,就绪态、运行态、僵尸态、可中断睡眠状态、不可中断睡眠状态和暂停态。
可中断睡眠状态也称为浅度睡眠,可被信号唤醒,不可中断睡眠状态也称深度睡眠,其不可被信号唤醒,等到相应的条件成立才能结束睡眠。浅度睡眠和深度睡眠统称为等待态或阻塞态,表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态,处于等待态的进程是无法参与进程系统调度的。
每个进程除了有一个进程ID、父进程ID之外,还有一个进程组ID,用于标识该进程属于哪一个进程组,进程组是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系。Linux系统设计进程组实质上是为了方便对进程进行管理,如果要终止某一个组内的所有进程,只需要终止进程组即可,不需要逐个去结束。
守护进程也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生。守护进程的特点是长期运行、与控制终端脱离。Linux中大多数服务器就是用守护进程实现的。
守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机。
进程通信
进程间通信(interprocess communication,简称IPC)指两个进程之间的通信,系统中的每一个进程都有各自的地址空间,并且相互独立、隔离,每个进程都处于自己的地址空间中。同一个进程的不同模块之间进行通信是比较简单的,比如使用全局变量等。
两个不同的进程之间进行通信通常是比较难的,因为这两个进程处于不同的地址空间中。
进程间通信方式有管道、信号、消息队列、信号量、共享内存、套接字等。
管道包括普通管道pipe、流管道s_pipe和有名管道FIFO。
普通管道用于具有亲缘关系的进程间通信,如父子进程、兄弟进程,并且数据只能单向传输,如果要实现双向传输,则必须要使用两个管道。流管道以半双工的方式实现双向传输,但也只能在具有亲缘关系的进程间通信。有名管道即可实现双向传输,也能在非亲缘关系的进程间通信。
信号用于通知接收信号的进程有某种事件发生,可用于进程间通信,进程还可以发送信号给进程本身。
消息队列是消息的链表,存放在内核中并由消息队列标识符标识,消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺陷。消息队列是UNIX下不同进程之间实现共享资源的一种机制,UNIX允许不同进程将格式化的数据流以消息队列形式发送给任意进程,有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。
信号量是一个计数器,主要用于控制多个进程间或一个进程内的多个线程间对共享资源的访问,相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同时进程也可以修改该标志。除了用于共享资源的访问控制外,还可用于进程同步,信号量常作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源。
共享内存就是映射一段能被其它进程所访问的内存,这段共享内存由一个进程创建,但其它进程都可以访问,这使得多个进程可以访问同一块内存空间。共享内存是最快的进程间通信方式,它是针对其它进程间通信方式运行效率低而专门设计的,它往往与其它通信机制来结合使用,比如信号量,以此实现进程间的同步和通信。
Socket是基于网络的进程间通信方法,允许位于同一主机或使用网络连接起来的不同主机上的应用程序之间交换数据,也就是网络通信。
线程
线程是处理器任务调度和执行的最小单位,它被包含在进程之中,是进程中的实际运行单位。一个线程指的是进程中一个单一顺序的控制流,一个进程中可以创建多个线程,多个线程实现并发运行,每个线程执行不同的任务。
当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程。应用程序都是以main()函数做为入口开始运行的,所以main()函数就是主线程的入口函数,main()函数所执行的任务就是主线程需要执行的任务。
任何一个进程都包含一个主线程,只有主线程的进程称为单线程进程。多线程指的是除了主线程以外,还有其它的线程,其它线程通常由主线程创建的,创建的新线程就是主线程的子线程。主线程通常会在最后结束运行,执行各种清理工作,回收各个子线程占用的资源。
线程是程序最基本的运行单位,进程不能运行,真正运行的是进程中的线程。当启动应用程序后,系统就创建了一个进程,可以认为进程仅仅是一个容器,它包含了线程运行所需的数据结构、环境变量等信息。同一进程中的多个线程共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等。同一进程中的多个线程有各自的调用栈、寄存器环境、线程本地存储等。
线程的特点:不是单独存在,包含在进程中;处理器任务调度和执行的最小单位;可并发执行;共享进程资源,有相同的地址空间。
进程创建多个子进程可以实现并发处理多任务,一个多线程进程也可以实现并发处理多任务。
多进程编程的劣势:进程间切换开销大;进程间通信麻烦。
多线程编程的优势:线程间的切换开销比较小;同一进程的多个线程在同一个地址空间中,因此通信容易;线程创建的速度远大于进程创建的速度;多线程在多核处理器上更有优势。
串行就是一件事一件事接着做;并发是交替做不同的事;并行是同时做不同的事。并行运行情况下的多个执行单元,每一个执行单元同样也可以以并发方式运行。
线程有其对应的标识线程ID,使用pthread_t数据类型来表示,一个线程可通过库函数pthread_self()来获取自己的线程ID。
#include <pthread.h>
pthread_t pthread_self(void);
int pthread_equal(pthread_t t1, pthread_t t2); //检查两个线程ID是否相等,相等返回非0值,不相等返回0
主线程可以使用库函数pthread_create()负责创建一个新的线程,创建出来的新线程被称为主线程的子线程。
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
thread保存新创建的线程ID;attr为线程属性,设置为NULL表示所有属性设置为默认值;新创建的线程从start_routine()函数开始运行;arg是传递给start_routine()函数的参数。成功返回0,失败时将返回一个错误号。
含有pthread_create函数时,在编译的时候需要加上-lpthread,使用-l选项指定链接库pthread,因为pthread不在gcc的默认链接库中,所以需要手动指定。
gcc -o test test.c -lpthread
gcc test.c -o test -lpthread
可使用下面的代码进行线程的创建。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <error.h>
#include <stdlib.h>
#include <pthread.h>
static void *new_thread_start(void *arg)
{
printf("新线程: 进程ID=%d 线程ID=%lu\n", getpid(), pthread_self());
return 0;
}
int main(int argc, char **argv)
{
pthread_t tid;
int ret;
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret)
{
printf("ret = %d\n", ret);
exit(-1);
}
printf("主线程: 进程ID=%d 线程ID=%lu\n", getpid(), pthread_self());
sleep(1);
exit(0);
}
终止线程可以在线程的start函数执行return语句并返回指定值,返回值就是线程的退出码;线程调用pthread_exit()函数;调用pthread_cancel()取消线程。
#include <pthread.h>
void pthread_exit(void *retval);
int pthread_cancel(pthread_t thread);
如果进程中的任意线程调用exit()、_exit()或者_Exit(),那么将会导致整个进程终止。
主线程如果调用了pthread_exit(),那么主线程也会终止,但其它线程依然正常运行,直到进程中的所有线程终止才会使得进程终止。
使用下面的程序可以测试在主线程结束之后,整个进程并没有结束,新的线程还在运行。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <error.h>
#include <stdlib.h>
#include <pthread.h>
static void *new_thread_start(void *arg)
{
printf("新线程start\n");
sleep(1);
printf("新线程end\n");
pthread_exit(NULL);
}
int main(int argc, char **argv)
{
pthread_t tid;
int ret;
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret)
{
fprintf(stderr, "Error: %s\n", strerror(ret));
//printf("ret = %d\n", ret);
exit(-1);
}
printf("主线程end\n");
pthread_exit(NULL);
exit(0);
}
调用pthread_join()函数来阻塞等待线程的终止,并获取线程的退出码,回收线程资源。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
调用pthread_detach()将指定线程进行分离,一个线程既可以将另一个线程分离,同时也可以将自己分离。
#include <pthread.h>
int pthread_detach(pthread_t thread);
一旦线程处于分离状态,就不能再使用pthread_join()来获取其终止状态,处于分离状态之后便不能再恢复到之前的状态。处于分离状态的线程终止后,能够自动回收线程资源。
可/不可重入函数
如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果,把这样的函数就称为可重入函数。
重入指的是同一个函数被不同执行流调用,前一个执行流还没有执行完该函数,另一个执行流又开始调用该函数了,就是同一个函数被多个执行流并发/并行调用,在宏观角度上理解指的就是被多个执行流同时调用。
在多线程环境以及信号处理有关应用程序中,需要注意不可重入函数的问题,如果多条执行流同时调用一个不可重入函数则可能会得不到预期的结果、甚至有可能导致程序崩溃。不止是在应用程序中,在一个包含了中断处理的裸机应用程序中亦是如此,所以不可重入函数通常存在着一定的安全隐患。
绝对可重入函数的特点:函数内所使用到的变量均为局部变量,即函数内的操作的内存地址均为本地栈地址;函数参数和返回值均是值类型;函数内调用的其它函数也均是绝对可重入函数。
如果函数内部对全局变量只读,且其他函数不修改这个全局变量,那么这个函数就是可重入函数;如果函数内部对全局变量要进行修改,那么该函数就是不可重入函数。
不可重入函数不可以在它还没有返回就再次被调用。函数不可重入大多数是因为在函数中引用了全局变量,比如,printf会引用全局变量stdout,malloc,free会引用全局的内存分配表。
在unix里面通常都有加上_r后缀的同名可重入函数版本。
判断一个函数是否为线程安全函数的方法是,该函数被多个线程同时调用是否总能产生正确的结果,如果每次都能产生预期的结果则表示该函数是一个线程安全函数。判断一个函数是否为可重入函数的方法是,该函数被多个执行流同时调用是否总能产生正确的结果,如果每次都能产生预期的结果则表示该函数是一个可重入函数。可重入函数是线程安全函数,线程安全函数不一定是可重入函数,因为不可重入函数可以通过互斥操作变为线程安全函数。
线程同步
线程同步是为了对共享资源的访问进行保护,保护的目的是为了解决数据一致性问题,数据一致性问题的本质就是进程中多个线程对共享资源的并发访问。
Linux系统提供了多种用于实现线程同步的机制,常见的方法有互斥锁、条件变量、自旋锁以及读写锁等。
互斥锁
互斥锁也叫互斥量,在访问共享资源之前对互斥锁进行上锁,在访问完成后释放互斥锁。对互斥锁上锁之后,任何其它试图再次对互斥锁进行加锁的线程都会被阻塞,直到当前线程释放互斥锁,如果释放互斥锁时有一个以上的线程阻塞,那么这些阻塞的线程会被唤醒,它们都会尝试对互斥锁进行加锁,当有一个线程成功对互斥锁上锁之后,其它线程就不能再次上锁了,只能再次陷入阻塞,等待下一次解锁。
互斥锁初始化,加锁,解锁的函数如下。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
初始化的示例。
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
pthread_mutex_t *mutex = malloc(sizeof(pthread_mutex_t)); //动态分配
pthread_mutex_init(mutex, NULL);
对处于未锁定状态的互斥锁进行解锁操作和解锁由其它线程锁定的互斥锁都是错误的行为。
使用互斥锁保护全局变量的代码示例如下。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <error.h>
#include <stdlib.h>
#include <pthread.h>
static pthread_mutex_t mutex;
static int g_count = 0;
static int loops;
static void *new_thread_start(void *arg)
{
int loops = *((int *)arg);
int l_count, j;
for (j = 0; j < loops; j++)
{
pthread_mutex_lock(&mutex); //互斥锁上锁
l_count = g_count;
l_count++;
g_count = l_count;
pthread_mutex_unlock(&mutex);//互斥锁解锁
}
}
int main(int argc, char **argv)
{
pthread_t tid1, tid2;
int ret;
if(argc != 2)
{
printf("error arguments!\n");
return -1;
}
loops = atoi(argv[1]); //接收用户传来的参数
/* 初始化互斥锁 */
pthread_mutex_init(&mutex, NULL);
/* 创建2个新线程 */
ret = pthread_create(&tid1, NULL, new_thread_start, &loops);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
ret = pthread_create(&tid2, NULL, new_thread_start, &loops);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
/* 等待线程结束 */
ret = pthread_join(tid1, NULL);
if (ret)
{
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
ret = pthread_join(tid2, NULL);
if (ret)
{
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("g_count = %d\n", g_count);
/* 销毁互斥锁 */
pthread_mutex_destroy(&mutex);
exit(0);
}
上面代码中创建了2个线程,它们的开始函数都是new_thread_start,而在该开始函数中都会对g_count这个全局变量进行修改,因此,对这个全局变量需要进行加锁,使得两个线程能够互斥修改访问全局变量,这样才能得到想要的结果。可以试着去掉互斥锁,这种情况在传入参数不大的情况下结果正确,但是当参数较大时,没有互斥锁就会得到错误的结果。
当互斥锁已经被其它线程锁住时,调用pthread_mutex_lock()函数会被阻塞,直到互斥锁解锁。如果线程不希望被阻塞,可以使用pthread_mutex_trylock()函数,该函数尝试对互斥锁进行加锁,如果互斥锁处于未锁住状态,那么调用pthread_mutex_trylock()将会锁住互斥锁并立马返回,如果互斥锁已经被其它线程锁住,调用pthread_mutex_trylock()加锁失败,但不会被阻塞,而是返回错误码EBUSY。
int pthread_mutex_trylock(pthread_mutex_t *mutex);
调用pthread_mutex_destroy()函数来销毁互斥锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
没有解锁的互斥锁不能销毁,没有初始化的互斥锁也不能销毁!被pthread_mutex_destroy()销毁之后的互斥锁,不能再对其进行上锁和解锁了,需要再次调用pthread_mutex_init()对互斥锁进行初始化之后才能使用。
条件变量
条件变量用于自动阻塞线程,知道某个特定事件发生或某个条件满足为止,通常情况下,条件变量和互斥锁一起搭配使用。
使用条件变量主要包括两个动作:一个线程等待某个条件满足而被阻塞;另一个线程中,条件满足时发出信号。典型的例子就是生产者消费者问题。
条件变量初始化与销毁函数如下。
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cond);
条件变量的主要操作便是发送信号和等待,发送信号操作即是通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变,这些处于等待状态的线程收到通知之后便会被唤醒,唤醒之后再检查条件是否满足,等待操作是指在收到一个通知前一直处于阻塞状态。函数pthread_cond_signal()和pthread_cond_broadcast()均可向指定的条件变量发送信号,通知一个或多个处于等待状态的线程。
int pthread_cond_broadcast(pthread_cond_t *cond); //唤醒所有线程
int pthread_cond_signal(pthread_cond_t *cond); //至少唤醒一个线程
判断条件不满足时,调用pthread_cond_wait()函数将线程设置为等待状态,即阻塞。
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
cond指向需要等待的条件变量;mutex指向一个互斥锁对象。该函数内部会对参数mutex所指定的互斥锁进行操作,在函数调用之前该线程已经对互斥锁加锁了,调用者把互斥锁传递给函数,函数会自动把调用线程放到等待条件的线程列表上,然后将互斥锁解锁,当该线程被唤醒时,会再次给互斥锁上锁。
条件变量和互斥锁的结合使用示例如下。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <error.h>
#include <stdlib.h>
#include <pthread.h>
static pthread_mutex_t mutex; //定义互斥锁
static pthread_cond_t cond; //定义条件变量
static int product = 0; //全局共享资源
/* 消费者线程 */
static void *consumer_thread(void *arg)
{
while(1)
{
pthread_mutex_lock(&mutex); //互斥锁上锁
while(product <= 0)
pthread_cond_wait(&cond, &mutex); //等待条件满足
while(product > 0)
{
product--; //条件满足,消费
printf("consume -1\n");
}
pthread_mutex_unlock(&mutex); //互斥锁解锁
}
}
int main(int argc, char **argv)
{
pthread_t tid;
int ret;
/* 初始化互斥锁和条件变量 */
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond, NULL);
/* 创建新线程 */
ret = pthread_create(&tid, NULL, consumer_thread, NULL);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
while(1)
{
pthread_mutex_lock(&mutex); //互斥锁上锁
product++; //生产
printf("produce +1\n");
pthread_mutex_unlock(&mutex); //互斥锁解锁
pthread_cond_signal(&cond); //向条件变量发送信号
sleep(1);
}
exit(0);
}
一开始将产品数量设置为0,生产者每秒生产一个,消费者没有时间限制,但是得等到有产品后才能消费,因此,上面代码的运行结果是,生产者每秒钟生产一个,消费者马上消费掉,然后生产者再生产,消费者等待消费这样。
自旋锁
自旋锁与互斥锁很相似,在访问共享资源之前对自旋锁进行上锁,在访问完成后释放自旋锁。从实现方式上来说,互斥锁是基于自旋锁来实现的,所以自旋锁相较于互斥锁更加底层。
如果在获取自旋锁时,其处于未锁定状态,那么线程将对自旋锁上锁,如果在获取自旋锁时,其已经处于锁定状态了,那么获取锁操作将会在原地“自旋”,直到该自旋锁的持有者释放了锁。自旋锁与互斥锁相似,但是互斥锁在无法获取到锁时会让线程陷入阻塞等待状态,而自旋锁在无法获取到锁时,将会在原地“自旋”等待。“自旋”其实就是调用者一直在循环查看该自旋锁的持有者是否已经释放了锁。
自旋锁的不足在于其在未获得锁的情况下一直占用着CPU,因为自旋锁一直处于“自旋”等待状态,如果不能在很短的时间内获取锁,这种操作会使CPU效率降低。互斥锁的休眠与唤醒开销是比较大的,自旋锁的效率要比互斥锁高。
自旋锁通常用于需要保护的代码段执行时间很短,这样就会使得持有锁的线程会很快释放锁,而“自旋”等待的线程也只需等待很短的时间,这种情况下使用自旋锁效率就会比较高。自旋锁在内核代码中使用比较多。
自旋锁的初始化与销毁函数如下。
#include <pthread.h>
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
自旋锁加锁和解锁的函数如下。
int pthread_spin_lock(pthread_spinlock_t *lock); //未获取到锁时自旋等待
int pthread_spin_trylock(pthread_spinlock_t *lock); //未获取到锁时返回错误,错误码为EBUSY
int pthread_spin_unlock(pthread_spinlock_t *lock);
使用自旋锁的示例如下。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <error.h>
#include <stdlib.h>
#include <pthread.h>
static pthread_spinlock_t spin; //定义自旋锁
static int g_count = 0; //全局变量
static int loops;
static void *new_thread_start(void *arg)
{
int loops = *((int *)arg);
int l_count, j;
for (j = 0; j < loops; j++)
{
pthread_spin_lock(&spin); //自旋锁上锁
l_count = g_count;
l_count++;
g_count = l_count;
pthread_spin_unlock(&spin); //自旋锁解锁
}
}
int main(int argc, char **argv)
{
pthread_t tid1, tid2;
int ret;
if(argc != 2)
{
printf("error arguments!\n");
return -1;
}
loops = atoi(argv[1]); //接收用户传来的参数
/* 初始化自旋锁 */
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
/* 创建2个新线程 */
ret = pthread_create(&tid1, NULL, new_thread_start, &loops);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
ret = pthread_create(&tid2, NULL, new_thread_start, &loops);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
/* 等待线程结束 */
ret = pthread_join(tid1, NULL);
if (ret)
{
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
ret = pthread_join(tid2, NULL);
if (ret)
{
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("g_count = %d\n", g_count);
/* 销毁自旋锁 */
pthread_spin_destroy(&spin);
exit(0);
}
读写锁
互斥锁或自旋锁只有加锁和不加锁两种状态,而且一次只有一个线程可以对其加锁。读写锁有三种状态,读模式下的加锁状态、写模式下的加锁状态和不加锁状态,一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁。读写锁比互斥锁具有更高的并行性,读写锁非常适合于对共享数据读的次数远大于写的次数的情况。
当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行的加锁操作,不管是以读模式还是以写模式加锁的线程都会被阻塞。当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功;但是任何以写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止。
读写锁也叫做共享互斥锁,当读写锁是读模式锁住时,就可以说成是共享模式锁住,当它是写模式锁住时,就可以说成是互斥模式锁住。
读写锁的初始化与销毁函数如下。
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
读写锁加锁和解锁的函数如下。
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); //以读模式对读写锁进行上锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); //以写模式对读写锁进行上锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); //解锁
需要注意的是,不管是以读模式还是写模式对读写锁进行上锁,解锁的函数都是一样的。
当读写锁处于写模式加锁状态时,其它线程调用pthread_rwlock_rdlock()或pthread_rwlock_wrlock()函数均会获取锁失败,从而陷入阻塞等待状态;当读写锁处于读模式加锁状态时,其它线程调用pthread_rwlock_rdlock()函数可以成功获取到锁,如果调用pthread_rwlock_wrlock()函数则不能获取到锁,从而陷入阻塞等待状态。
如果线程不希望被阻塞,可以调用pthread_rwlock_tryrdlock()和pthread_rwlock_trywrlock()来尝试加锁,如果不可以获取锁时,这两个函数都会立马返回错误,错误码为EBUSY。
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
读写锁的代码示例如下。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <error.h>
#include <stdlib.h>
#include <pthread.h>
static pthread_rwlock_t rwlock; //定义读写锁
static int g_count = 0;
static int nums[5] = {0, 1, 2, 3, 4};
static void *read_thread(void *arg)
{
int number = *((int *)arg);
int j;
for (j = 0; j < 10; j++)
{
pthread_rwlock_rdlock(&rwlock); //以读模式获取锁
printf("读线程<%d>, g_count=%d\n", number+1, g_count);
pthread_rwlock_unlock(&rwlock); //解锁
sleep(1);
}
}
static void *write_thread(void *arg)
{
int number = *((int *)arg);
int j;
for (j = 0; j < 10; j++)
{
pthread_rwlock_wrlock(&rwlock); //以写模式获取锁
printf("写线程<%d>, g_count=%d\n", number+1, g_count+=20);
pthread_rwlock_unlock(&rwlock); //解锁
sleep(1);
}
}
int main(int argc, char **argv)
{
pthread_t tid[10];
int j;
/* 对读写锁进行初始化 */
pthread_rwlock_init(&rwlock, NULL);
/* 创建5个读g_count变量的线程 */
for (j = 0; j < 5; j++)
pthread_create(&tid[j], NULL, read_thread, &nums[j]);
/* 创建5个写g_count变量的线程 */
for (j = 0; j < 5; j++)
pthread_create(&tid[j+5], NULL, write_thread, &nums[j]);
/* 等待线程结束 */
for (j = 0; j < 10; j++)
pthread_join(tid[j], NULL); //回收线程
/* 销毁自旋锁 */
pthread_rwlock_destroy(&rwlock);
exit(0);
}
上面代码的运行结果如下图所示。
由上图的运行结果可以看到,读的线程读到的值肯定是最新写进去的,而且写操作每执行一次,全局变量的数值就加20。
I/O操作
阻塞/非阻塞I/O
阻塞其实就是进入了休眠状态,交出了CPU控制权,wait()、pause()、sleep()等函数都会进入阻塞。
对于有些文件,比如管道文件,如果当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才被唤醒,这是阻塞式I/O;普通文件的读写操作是以非阻塞的方式进行I/O操作的。
使用open函数时,为参数flags指定O_NONBLOCK标志,文件就会以非阻塞方式进行,如果不指定O_NONBLOCK标志,则默认以阻塞方式打开。
在ubuntu下的/dev/input目录下使用下面的命令测试鼠标和键盘。
sudo od -x /dev/input/event1
sudo od -x /dev/input/event2
具体对应的哪个事件要自己测试,我的键盘对应event1,鼠标对应event2,执行上面的命令后,动一下鼠标或者敲击键盘上的按键都会有数据输出,如下图所示。
可以写如下测试程序测试鼠标。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <error.h>
#include <stdlib.h>
#include <pthread.h>
int main(int argc, char **argv)
{
char buf[100];
int fd, ret;
fd = open("/dev/input/event2", O_RDONLY | O_NONBLOCK);
if (fd < 0)
{
perror("open error");
exit(-1);
}
memset(buf, 0, sizeof(buf));
while(1)
{
ret = read(fd, buf, sizeof(buf));
if (ret > 0)
{
printf("成功读取%d个字节数据\n", ret);
}
}
close(fd);
exit(0);
}
使用上面的程序以非阻塞方式打开鼠标对应的文件,鼠标不移动时读取到的字节数是48,移动的时候读取到的字节数就是72,如下图所示。
当对文件进行读取操作时,如果文件当前无数据可读,那么阻塞式I/O会将调用者应用程序挂起,进入休眠阻塞状态,直到有数据可读时才会解除阻塞。对于非阻塞I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮训等待,直到数据可读,要么直接放弃。阻塞式I/O的优点在于能够提升CPU的处理效率,当自身条件不满足时,进入阻塞状态,交出CPU资源供其他人使用。
键盘是标准输入设备stdin,进程会自动从父进程中继承标准输入、标准输出以及标准错误,标准输入设备对应的文件描述符为0,所以测试键盘的时候直接将文件描述符设置为0即可。键盘的测试如下图所示。
键盘是阻塞方式读的,将其设置为非阻塞方式使用fcntl()函数,设置的代码如下。
int flag;
flag = fcntl(0, F_GETFL); //先获取原来的flag
flag |= O_NONBLOCK; //将O_NONBLOCK标志添加到flag
fcntl(0, F_SETFL, flag); //重新设置flag
并发的读取鼠标和键盘的代码示例如下。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <error.h>
#include <stdlib.h>
#include <pthread.h>
int main(int argc, char **argv)
{
char buf[100];
int fd, ret, flag;
/* 打开鼠标设备文件 */
fd = open("/dev/input/event2", O_RDONLY | O_NONBLOCK);
if (fd < 0)
{
perror("open error");
exit(-1);
}
/* 将键盘设置为非阻塞方式 */
flag = fcntl(0, F_GETFL); //先获取原来的flag
flag |= O_NONBLOCK; //将O_NONBLOCK标志添加到flag
fcntl(0, F_SETFL, flag); //重新设置flag
while(1)
{
ret = read(fd, buf, sizeof(buf)); //读鼠标
if (ret > 0)
{
printf("鼠标---成功读取%d个字节数据\n", ret);
}
ret = read(0, buf, sizeof(buf)); //读键盘
if (ret > 0)
{
printf("键盘---成功读取%d个字节数据\n", ret);
}
}
close(fd);
exit(0);
}
程序运行结果如下图所示。
I/O多路复用
I/O多路复用通过一种机制可以监视多个文件描述符,一旦某个文件描述符可以执行I/O操作时,系统就能够通知应用程序进行相应的读写操作。I/O多路复用技术是为了解决在并发式I/O场景中进程或线程阻塞到某个I/O系统调用而出现的技术,使进程不阻塞于某个特定的I/O系统调用。I/O多路复用明显的特征是外部阻塞式,内部监视多路I/O。
系统调用select()或者poll()函数可用于执行I/O多路复用操作,调用函数后会一直阻塞,直到某一个或多个文件描述符成为就绪态。
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
存储映射I/O
存储映射I/O是一种基于内存区域的高级I/O操作,它能将一个文件映射到进程地址空间中的一块内存区域中,当从这段内存中读数据时,就相当于读文件中的数据,将数据写入这段内存时,则相当于将数据直接写入文件中。这样就可以在不使用基本I/O操作函数read()和write()的情况下执行I/O操作。
普通I/O方式一般是通过调用read()和write()函数来实现对文件的读写,使用read()和write()读写文件时,函数经过层层的调用后,才能够最终操作到文件,中间涉及到很多的函数调用过程,数据需要在不同的缓存间传递,效率会比较低。
比如复制一个文件,普通I/O方式首先需要将源文件中的数据读取出来存放在一个应用层缓冲区中,接着再将缓冲区中的数据写入到目标文件中。
对于存储映射I/O来说,由于源文件和目标文件都已映射到了应用层的内存区域中,所以直接操作映射区来实现文件复制。
存储映射I/O将文件映射到应用程序地址空间中的一块内存区域中,即映射区,将磁盘文件与映射区关联起来,不用再调用read()、write(),直接对映射区的文件进行读写操作即可操作磁盘上的文件,而磁盘文件中的数据也可反应到映射区中,这就是一种共享,可以认为映射区就是应用层与内核层之间的共享内存。
存储映射I/O方式的不足是其所映射的文件只能是固定大小,因为文件所映射的区域已经在调用mmap()函数时通过length参数指定了。此外,文件映射的内存区域的大小必须是系统页大小的整数倍,比如映射文件的大小为96字节,假定系统页大小为4096字节,那么剩余的4000字节全部填充为0,虽然可以通过映射地址访问剩余的这些字节数据,但不能在映射文件中反应出来,由此可知,使用存储映射I/O在进行大数据量操作时比较有效,对于少量数据,使用普通I/O方式更加方便。
存储映射I/O在处理大量数据时效率要比普通I/O高。
参考资料:
I.MX6U嵌入式Linux C应用编程指南V1.4——正点原子