Linux 线程简介
Linux 中的线程是指轻量级的执行单元,相比于进程,具有以下特点:
(1)进程(Process)是正在执行的程序的实例。每个进程都有自己的地址空间、代码段、数据段和打开的文件描述符等资源。线程(Thread)是进程内的一个执行单元,它共享相同的地址空间和其他资源,包括文件描述符、信号处理等,但每个线程都有自己的栈空间。
(2)由于共享地址空间和数据段,同一进程的多线程之间进行数据交换比进程间通信方便很多,但也由此带来线程同步问题。
(3)同一进程的多线程共享大部分资源,除了每个线程独立的栈空间。这代表线程的创建、销毁、切换要比进程的创建、销毁、切换的资源消耗小很多,所以多线程比多进程更适合高并发。
在/home/atguigu 下创建thread_test 目录,本章的所有测试例程都会放到该目录下。
线程控制
线程创建
pthread_create
线程操作相关函数来源于pthread共享库:
#include <pthread.h>
/**
* 创建一个新线程
*
* pthread_t *thread: 指向线程标识符的指针,线程创建成功时,用于存储新创建线程的线程标识符
* const pthread_attr_t *attr: pthead_attr_t 结构体,这个参数可以用来设置线程的属性,如优先级、栈大
* 小等。如果不需要定制线程属性,可以传入 NULL,此时线程将采用默认属性。
* void *(*start_routine)(void *): 一个指向函数的指针,它定义了新线程开始执行时的入口点。这个函数
* 必须接受一个 void * 类型的参数,并返回 void * 类型的结果
* void *arg: start_routine 函数的参数,可以是一个指向任意类型数据的指针
* return: int 线程创建结果
*成功 0
*失败 非0
*/
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void*(*start_routine)(void *), void *arg);
每个线程都有一个唯一的标识符(即线程ID),这个标识符是通过pthread_t类型的变量来表示的,当pthread_create成功创建一个线程时,它会将新线程的标识符存储在thread 参数指向的位置。
pthread_t 定义在头文件<pthreadtypes.h>中,实际上是long 类型(long 和long int 是相同类型的不同写法)的别名。
typedef unsigned long int pthread_t;
新线程执行函数的声明为void *(*start_routine)(void *),其入参和返回值都是void *指针。我们可以将传递给线程函数的参数包装为结构体,并将其指针作为入参,再在函数内部处理;同理我们可以在线程函数内部将要返回的状态码和返回值包装为结构体,并将指针作为返回值return。
返回值的获取需要通过其他方法进行,在下面的章节我们会讲。
测试例程
创建create_test.c:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define BUF_LEN 1024
char *buf;
//读线程代码逻辑(运行入口)
void *input_thread(void *argv){
int i = 0;
while(1){
char c = fgetc(stdin);
if(c && c != '\n'){
buf[i++] = c;
}
// 缓冲区索引溢出
if (i >= BUF_LEN){
i = 0;
}
}
}
//写线程代码逻辑(运行入口)
void *output_thread(void *argv){
int i = 0;
while(1){
if (buf[i]){
fputc(buf[i],stdout);
fputc('\n',stdout);
buf[i++] = 0;
if (i >= BUF_LEN){
i = 0;
}
}
else{
sleep(1);
}
}
}
//创建两个线程
//(1)读取控制台信息 写入到缓存中
//(2)将缓存信息写出到控制台
int main(){
//初始化buf
buf = calloc(BUF_LEN,sizeof(char));
//线程ID
pthread_t pid_input,pid_output;
//创建读线程
pthread_create(&pid_input,NULL,input_thread,NULL);
//创建写线程
pthread_create(&pid_output,NULL,output_thread,NULL);
//主线程等待读写线程结束
pthread_join(pid_input,NULL);
pthread_join(pid_output,NULL);
free(buf);
return 0;
}
上述代码启动了两个线程,输入线程不断检查stdin,将控制台输入的数据存储到缓冲区,输出线程检查缓冲区,如果缓冲区有数据则逐字符输出到stdout,否则等待1s再检查缓冲区。
线程终止
相关函数
pthread_exit
线程终止有以下几种方法:
(1)线程函数执行return语句;
(2)线程函数内部调用pthread_exit函数;
(3)其他线程调用pthread_cancel函数。
线程终止函数为pthread_exit:
#include <pthread.h>
/**
* 结束关闭调用该方法的线程,并返回一个内存指针用于存放结果
* void *retval: 要返回给其它线程的数据
*/
void pthread_exit(void *retval);
当某个线程调用pthread_exit方法后,该线程会被关闭(相当于return)。线程可以通过retval向其它线程传递信息,retval指向的区域不可以放在线程函数的栈内。其他线程(例如主线程)如果需要获得这个返回值,需要调用pthread_join方法。
pthread_join
#include <pthread.h>
/**
* 等待指定线程结束,获取目标线程的返回值,并在目标线程结束后回收它的资源
*
* pthread_t thread: 指定线程ID
* void **retval: 这是一个可选参数,用于接收线程结束后传递的返回值。如果非空,pthread_join 会在成
* 功时将线程的 exit status 复制到 *retval 所指向的内存位置。如果线程没有显式地通过 pthread_exit
* 提供返回值,则该参数将被设为 NULL 或忽略
* return: int
* 成功 0
* 失败 1
*/
int pthread_join(pthread_t thread, void **retval);
pthread_detach
#include <pthread.h>
/**
* @brief将线程标记为detached状态。POSIX线程终止后,如果没有调用pthread_detach或pthread_join,其
* 资源会继续占用内存,类似于僵尸进程的未回收状态。默认情况下创建线程后,它处于可join状态,此时可以
* 调用pthread_join等待线程终止并回收资源。但是如果主线程不需要等待线程终止,可以将其标记为
* detached状态,这意味着线程终止后,其资源会自动被系统回收。
* @param thread线程ID
* @return int成功返回0,失败返回错误码
*/
int pthread_detach(pthread_t thread);
datach方法与join相比,不需要阻塞等待,只是标记该线程。
pthread_cancel
#include <pthread.h>
/**
* @brief向目标线程发送取消请求。目标线程是否和何时响应取决于它的取消状态和类型
*取消状态(Cancelability State):可以是enabled(默认)或disabled。如果
取消状态为禁用,则取消请求会被挂起,直至线程启用取消功能。如果取消状态为启用,
则线程的取消类型决定它何时取消。
*取消类型(Cancelability Type):可以是asynchronous(异步)或deferred
(被推迟,默认值)。
* asynchronous:意味着线程可能在任何时候被取消(通常立即被取消,但系统
并不保证这一点)
* deferred:被推迟意味着取消请求会被挂起,直至被取消的线程执行取消点
(cancellation point)函数时才会真正执行线程的取消操作。
*取消点函数:是在POSIX线程库中专门设计用于检查和处理取消请求的函数。当被取消的线程执行这些函数时,
如果线程的取消状态是enabled且类型是deferred,则它会立即响应取消请求并终止执行。man 7 pthreads可
以看到取消点函数列表。
*
* @param thread目标线程,即被取消的线程
* @return int成功返回0,失败返回非零的错误码
*需要注意的是,取消操作和pthread_cancel函数的调用是异步的,这个函数
的返回值只能告诉调用者取消请求是否成功发送。当线程被成功取消后,通过
pthread_join和线程关联将会获得PTHREAD_CANCELED作为返回信息,这是判断取消是
否完成的唯一方式
*/
int pthread_cancel(pthread_t thread);
发送取消请求,如果线程的属性是默认值那么它会立刻响应该请求并执行取消点函数,但是该函数的返回值只代表成功发送取消请求,并不代表线程取消成功。
pthread_setcancelstate
#include <pthread.h>
/**
* @brief设置调用线程的取消状态
* PTHREAD_CANCEL_ENABLE:启用取消功能
* PTHREAD_CANCEL_DISABLE:禁用取消功能
*
* @param state目标状态
* @param oldstate指针,用于返回历史状态
* @return int成功返回0,失败返回非零错误码
*/
int pthread_setcancelstate(int state, int *oldstate);
pthread_setcanceltype
#include <pthread.h>
/**
* @brief设置调用线程的取消类型
* PTHREAD_CANCEL_DEFERRED:设置取消类型为推迟
* PTHREAD_CANCEL_ASYNCHRONOUS:设置取消类型为异步
*
* @param type目标类型
* @param oldtype指针,用于接收历史类型
* @return int成功返回0,失败返回非零错误码
*/
int pthread_setcanceltype(int type, int *oldtype);
一般情况下,线程的取消状态与取消类型都为默认设置
测试例程
pthread_join测试例程
创建terminate_test.c:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <math.h>
#include <string.h>
//定义结果结构体
typedef struct Result
{
char *p;
int len;
} Result;
/**
*红玫瑰
*
* void *argv:指针,可以传递的参数这里用一个字符表示她的代号
* return: void*结果结构体指针返回结局
*/
void *red_thread(void *argv)
{
Result *result = (Result *)malloc(sizeof(Result));
char code = *((char *)argv);
//存放回信
char *ans = (char *)malloc(101);
while (1)
{
fgets(ans,100,stdin);
if (ans[0] == code)
{
//接收到了对应的信息
free(ans);
printf("红玫瑰离开了!\n");
char *redAns = strdup("红玫瑰独自去了纽约.\n");
result->p = redAns;
result->len =strlen(redAns);
//结束线程输出返回值
pthread_exit((void *)result);
}else{
printf("红玫瑰还在等你!\n");
}
}
}
/**
*白玫瑰
*
* void *argv:指针,可以传递的参数这里用一个字符表示她的代号
* return: void*结果结构体指针返回结局
*/
void *white_thread(void *argv)
{
Result *result = (Result *)malloc(sizeof(Result));
char code = *((char *)argv);
//存放回信
char *ans = (char *) malloc(101);
while (1)
{
fgets(ans,100,stdin);
if (ans[0]==code)
{
//接收到了对应的信息
free(ans);
printf("白玫瑰离开了!\n");
char *redAns = strdup("白玫瑰独自去了伦敦.\n");
result->p = redAns;
result->len =strlen(redAns);
//结束线程输出返回值
pthread_exit((void *)result);
}else{
printf("白玫瑰还在等你!\n");
}
}
}
int main()
{
pthread_t pid_red;
pthread_t pid_white;
char red_code = 'r';
char white_code = 'w';
Result *red_result = NULL;
Result *white_result = NULL;
//创建红玫瑰线程
pthread_create(&pid_red, NULL, red_thread, &red_code);
//创建白玫瑰线程
pthread_create(&pid_white, NULL, white_thread, &white_code);
//获取红玫瑰结果
pthread_join(pid_red,(void **)&red_result);
printf("红玫瑰故事结局:%s\n",red_result->p);
//释放内存
free(red_result->p);
free(red_result);
//获取白玫瑰结果
pthread_join(pid_white, (void **)&white_result);
printf("白玫瑰故事结局:%s\n",white_result->p);
//释放内存
free(white_result->p);
free(white_result);
return 0;
}
pthread_detach测试例程
创建pthread_detach_test.c:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void *task(void *arg)
{
printf("Thread started\n");
sleep(2); //模拟线程工作
printf("Thread finished\n");
return NULL;
}
int main()
{
pthread_t tid;
//创建线程
pthread_create(&tid, NULL, task, NULL);
//使用pthread_detach让线程自动回收资源
pthread_detach(tid);
//主线程继续工作
printf("Main thread continues\n");
sleep(3);
//需要注意的是,pthread_detach不会等待子线程结束,如果在后者执行完毕之前主线程退出,则整个进程
//退出,子线程被强制终止,因此需要等待足够的时间确保子线程完成自己的任务
printf("Main thread ending\n");
return 0;
}
需要注意的是,pthread_detach 不会等待子线程结束,如果在后者执行完毕之前主线程退出,则整个进程退出,子线程被强制终止。
pthread_cancel测试例程
创建pthread_cancel_deferred_test.c:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void *task(void *arg)
{
printf("Thread started\n");
//默认取消类型为延迟,无需设置
//模拟工作
printf("Working...\n");
sleep(1); //模拟工作
pthread_testcancel(); //取消点函数
printf("After Cancelled\n");
return NULL;
}
int main()
{
pthread_t tid;
void *res;
//创建线程
pthread_create(&tid, NULL, task, NULL);
//取消子线程
if (pthread_cancel(tid) != 0){
perror("pthread_cancel");
}
//等待子线程终止并获取其退出状态
pthread_join(tid, &res);
//检查子线程是否被取消
if (res == PTHREAD_CANCELED)
{
printf("Thread was canceled\n");
}
else
{
printf("Thread was not canceled, exit code: %ld\n", (long)res);
}
return 0;
}
pthread_setcanceltype设置异步取消方式
创建pthread_cancel_disabled_test.c:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void *task(void *arg)
{
printf("Thread started\n");
//默认取消类型为延迟
//设置异步取消方式
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);
//模拟工作
printf("Working...\n");
int i = 0;
while(1){
printf("%d\n",i++);
}
pthread_testcancel(); //取消点函数
printf("After Cancelled\n");
return NULL;
}
int main()
{
pthread_t tid;
void *res;
//创建线程
pthread_create(&tid, NULL, task, NULL);
//取消子线程
if (pthread_cancel(tid) != 0){
perror("pthread_cancel");
}
//等待子线程终止并获取其退出状态
pthread_join(tid, &res);
//检查子线程是否被取消
if (res == PTHREAD_CANCELED)
{
printf("Thread was canceled\n");
}
else
{
printf("Thread was not canceled, exit code: %ld\n", (long)res);
}
return 0;
}
可以看到每次取消时打印到i的值都不一样,表明异步取消在接收到取消请求后允许线程在任何时刻被取消,包括正在执行的任意代码段。而延迟取消需要等到特定的取消点pthread_create。
pthread_setcancelstate设置取消状态
创建pthread_cancel_disabled_test.c:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void *task(void *arg)
{
printf("Thread started\n");
//默认取消类型为延迟
//设置禁用取消状态
//模拟工作
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE,NULL);
printf("Working...\n");
int i = 0;
sleep(2);
pthread_testcancel(); //取消点函数
printf("After Cancelled\n");
return NULL;
}
int main()
{
pthread_t tid;
void *res;
//创建线程
pthread_create(&tid, NULL, task, NULL);
//取消子线程
if (pthread_cancel(tid) != 0){
perror("pthread_cancel");
}
//等待子线程终止并获取其退出状
pthread_join(tid, &res);
//检查子线程是否被取消
if (res == PTHREAD_CANCELED)
{
printf("Thread was canceled\n");
}
else
{
printf("Thread was not canceled, exit code: %ld\n", (long)res);
}
return 0;
}
线程同步
竞态条件和锁
竞态条件
当多个线程并发访问和修改同一个共享资源(如全局变量)时,如果没有适当的同步措施,就会遇到线程同步问题。这种情况下,程序最终的结果依赖于线程执行的具体时序,导致了竞态条件。
竞态条件(race condition)是一种特定的线程同步问题,指的是两个或者以上进程或者线程并发执行时,其最终的结果依赖于进程或者线程执行的精确时序。它会导致程序的行为和输出超出预期,因为共享资源的最终状态取决于线程执行的顺序和时机。为了确保程序执行结果的正确性和预期一致,需要通过适当的线程同步机制来避免竞态条件。
上一节的测试例程存在竞态条件,如果命令行输入的消息不以r或w开头,输出信息的线程是不确定的。
如何避免竞态条件
如果想避免竞态条件,有下面两种解决方案:
(1)避免多线程写入一个地址。
(2)给资源加锁,使同一时间操作特定资源的线程只有一个。
方法1可以通过逻辑上组织业务逻辑实现,这里我们讲方法2。想解决竞争问题,我们需要互斥锁——mutex。
常见的锁机制
锁主要用于互斥,即在同一时间只允许一个执行单元(进程或线程)访问共享资源。
包括上面的互斥锁在内,常见的锁机制共有三种:
(1)互斥锁(Mutex):保证同一时刻只有一个线程可以执行临界区的代码。
(2)读写锁(Reader/Writer Locks):允许多个读者同时读共享数据,但写者的访问是互斥的。
(3)自旋锁(Spinlocks):在获取锁之前,线程在循环中忙等待,适用于锁持有时间非常短的场景,一般是Linux内核使用。
互斥锁
pthread_mutex_t
定义
pthread_mutex_t 是一个定义在头文件<pthreadtypes.h>中的联合体类型的别名,其声明如下。
typedef union
{
struct __pthread_mutex_s __data;
char __size[__SIZEOF_PTHREAD_MUTEX_T];
long int __align;
} pthread_mutex_t;
pthread_mutex_t 用作线程之间的互斥锁。互斥锁是一种同步机制,用来控制对共享资源的访问。在任何时刻,最多只能有一个线程持有特定的互斥锁。如果一个线程试图获取一个已经被其他线程持有的锁,那么请求锁的线程将被阻塞,直到锁被释放。
用途
保护共享数据,避免同时被多个线程访问导致的数据不一致问题。
实现线程间的同步,确保线程之间对共享资源的访问按照预定的顺序进行。
操作
初始化(pthread_mutex_init):创建互斥锁并初始化。
锁定(pthread_mutex_lock):获取互斥锁。如果锁已经被其他线程持有,调用线程将阻塞。
尝试锁定(pthread_mutex_trylock):尝试获取互斥锁。如果锁已被持有,立即返回而不是阻塞。
解锁(pthread_mutex_unlock):释放互斥锁,使其可被其他线程获取。
销毁(pthread_mutex_destroy):清理互斥锁资源。
互斥锁操作函数
互斥锁相关的操作函数是POSIX的一部分,默认情况下,当前的Ubuntu系统没有这些函数的手册页。使用指令安装POSIX标准的手册页。
atguigu@ubuntu:~$ sudo apt-get install manpages-posix manpages posix-dev
查看手册中关于互斥锁操作函数的说明。
atguigu@ubuntu:~$ man 3 pthread_mutex_lock
查看文档可得:
#include <pthread.h>
/**
* @brief获取锁,如果此时锁被占则阻塞
*
* @param mutex锁
* @return int获取锁结果
*/
int pthread_mutex_lock(pthread_mutex_t *mutex);
/**
* @brief非阻塞式获取锁,如果锁此时被占则返回EBUSY
*
* @param mutex锁
* @return int获取锁结果
*/
int pthread_mutex_trylock(pthread_mutex_t *mutex);
/**
* @brief释放锁
*
* @param mutex锁
* @return int释放锁结果
*/
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_lock
该函数用于锁定指定的互斥锁。如果互斥锁已经被其他线程锁定,调用此函数的线程将会被阻塞,直到互斥锁变为可用状态。这意味着如果另一个线程持有锁,当前线程将等待直到锁被释放。
成功时返回0;失败时返回错误码。
pthread_mutex_trylock
该函数尝试锁定指定的互斥锁。与pthread_mutex_lock不同,如果互斥锁已经被其他线程锁定,pthread_mutex_trylock 不会阻塞调用线程,而是立即返回一个错误码(EBUSY)。
如果成功锁定互斥锁,则返回0;如果互斥锁已被其他线程锁定,返回EBUSY;其他错误情况返回不同的错误码。
pthread_mutex_unlock
该函数用于解锁指定的互斥锁。调用线程必须是当前持有互斥锁的线程;否则,解锁操作可能会失败。成功时返回0;失败时返回错误码。
初始化互斥锁
PTHREAD_MUTEX_INITIALIZER 是 POSIX 线程(Pthreads)库中定义的一个宏,用于静态初始化互斥锁(mutex)。这个宏为互斥锁提供了一个初始状态,使其准备好被锁定和解锁,而不需要在程序运行时显式调用初始化函数。
当我们使用PTHREAD_MUTEX_INITIALIZER 初始化互斥锁时,实际上是将互斥锁设置为默认属性和未锁定状态。这种初始化方式适用于简单的同步问题,我们可以通过以下代码初始化互斥锁。
将mutex加入程序
为了保证计算结果的正确性,很显然,我们应阻塞式获取互斥锁,应调用的是pthread_mutex_lock 函数。共享变量修改完成后,应该释放锁。
创建mutex_test.c:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define THREAD_COUNT 20000
// 初始化锁
static pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER;
/**
* @brief对传入值累加1
*
* @param argv传入指针
* @return void*无返回值
*/
void *add_thread(void *argv)
{
int *p = (int *)argv;
//在累加之前 获取锁 保证同一时间只有一个线程对其累加
pthread_mutex_lock(&counter_mutex);
(*p)++;
//累加之后 释放锁
pthread_mutex_unlock(&counter_mutex);
return (void *)0;
}
int main()
{
pthread_t pid[THREAD_COUNT];
int num = 0;
//用20000个线程对num作累加
for (int i = 0; i < THREAD_COUNT; i++)
{
pthread_create(pid + i, NULL, add_thread, &num);
}
//等待所有线程结束
for (int i = 0; i < THREAD_COUNT; i++)
{
pthread_join(pid[i], NULL);
}
//打印累加结果
printf("累加结果:%d\n", num);
return 0;
}
注意
上述代码中,互斥锁counter_mutex并未被显式销毁,但这通常不会引起资源泄露问题。因为锁是静态定义的(static关键字)。上述程序在所有线程执行完毕后直接结束,进程结束时,操作系统会回收该进程的所有资源,包括内存、打开的文件描述符和互斥锁等。因此即便没有显式销毁互斥锁也不会有问题。
在某些情况下,确实需要显式销毁互斥锁资源。如果互斥锁是动态分配的(使用pthread_mutex_init 函数初始化),或者互斥锁会被跨多个函数或文件使用,不再需要时必须显式销毁它。但对于静态初始化,并且在程序结束时不再被使用的互斥锁(上述程序中的counter_mutex),显式销毁不是必需的。
读写锁
工作原理
读操作:在读写锁的控制下,多个线程可以同时获得读锁。这些线程可以并发地读取共享资源,但它们的存在阻止了写锁的授予。
写操作:如果至少有一个读操作持有读锁,写操作就无法获得写锁。写操作将会阻塞,直到所有的读锁都被释放。
相关调用
pthread_rwlock_t
typedef union
{
struct __pthread_rwlock_arch_t __data;
char __size[__SIZEOF_PTHREAD_RWLOCK_T];
long int __align;
} pthread_rwlock_t;
pthread_rwlock_init()
/**
* @brief 为rwlock指向的读写锁分配所有需要的资源,并将锁初始化为未锁定状态。
读写锁的属性由attr参数指定,如果attr为NULL,则使用默认属性。当锁的属性为默
认时,可以通过宏PTHREAD_RWLOCK_INITIALIZER初始化,即
* pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;效果和调用当前方
法并为attr传入NULL是一样的
*
* @param rwlock读写锁
* @param attr读写锁的属性
* @return int成功则返回0,否则返回错误码
*/
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlock attr_t *restrict attr);
pthread_rwlock_destroy()
#include <pthread.h>
/**
* @brief 销毁rwlock指向的读写锁对象,并释放它使用的所有资源。当任何线程持有
锁的时候销毁锁,或尝试销毁一个未初始化的锁,结果是未定义的。
*
* @param rwlock
* @return int
*/
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
pthread_rwlock_rdlock()
——
/*
* @brief 应用一个读锁到rwlock指向的读写锁上,并使调用线程获得读锁。如果写线程持有锁,调用线程无法获得读锁,
* 它会阻塞直至获得锁。
*
* @param rwlock读写锁
* @return int成功返回0,失败返回错误码
*/
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
pthread_rwlock_wrlock()
/**
* @brief应用一个写锁到rwlock指向的读写锁上,并使调用线程获得写锁。只要任意
线程持有读写锁,则调用线程无法获得写锁,它将阻塞直至获得写锁。
*
* @param rwlock读写锁
* @return int成功返回0,失败返回错误码
*/
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
pthread_rwlock_unlock()
/**
* @brief释放调用线程锁持有的rwlock指向的读写锁。
*
* @param rwlock读写锁
* @return int成功返回0.失败返回错误码
*/
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
写操作添加读写锁
创建rwlock_test.c,写入以下内容:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_rwlock_t rwlock;
int shared_data = 0;
void *lock_reader(void *argv) {
pthread_rwlock_rdlock(&rwlock);
printf("this is %s, value is %d\n", (char *)argv, shared_data);
pthread_rwlock_unlock(&rwlock);
}
void *lock_writer(void *argv) {
pthread_rwlock_wrlock(&rwlock);
int tmp = shared_data + 1;
sleep(1);
shared_data = tmp;
printf("this is %s, shared_data++\n", (char *)argv);
pthread_rwlock_unlock(&rwlock);
}
int main() {
pthread_rwlock_init(&rwlock, NULL);
pthread_t writer1, writer2, reader1, reader2, reader3, reader4,
reader5, reader6;
pthread_create(&writer1, NULL, lock_writer, "writer1");
pthread_create(&writer2, NULL, lock_writer, "writer2");
sleep(3);
pthread_create(&reader1, NULL, lock_reader, "reader1");
pthread_create(&reader2, NULL, lock_reader, "reader2");
pthread_create(&reader3, NULL, lock_reader, "reader3");
pthread_create(&reader4, NULL, lock_reader, "reader4");
pthread_create(&reader5, NULL, lock_reader, "reader5");
pthread_create(&reader6, NULL, lock_reader, "reader6");
pthread_join(writer1,NULL);
pthread_join(writer2,NULL);
pthread_join(reader1,NULL);
pthread_join(reader2,NULL);
pthread_join(reader3,NULL);
pthread_join(reader4,NULL);
pthread_join(reader5,NULL);
pthread_join(reader6,NULL);
pthread_rwlock_destroy(&rwlock);
}
读写操作执行顺序随机
在上一节的基础上,我们对程序做以下改动:
①删除写操作的sleep()操作
②删除主线程中创建写线程之后的睡眠操作
③将第二次写操作置于第三次读操作之后。
这样做的目的是尽可能让读写操作间隔执行,但要注意的是,线程的执行顺序是由操作系统内核调度的,其运行规律并不简单地为“先创建先执行”
此时读写进程执行的顺序是不确定的。
写饥饿测试
在上一节的基础上,在读操作中添加1s的休眠。多次运行后,我们发现,此时读操作总是连续执行的,且读操作休眠未结束时,写操作会被阻塞。与工作原理相符:
①读操作可以并发执行,相互之间不必争抢锁,多个读操作可以同时获得读锁;
② 只要有一个线程持有读写锁,写操作就会被阻塞。
我们在读操作中加了1s休眠,只要有一个读线程获得锁,在1s内写操作是无法执行的,其它读操作就可以有充足的时间执行,因此读操作就会连续发生,写操作必须等待所有读操作执行完毕
方可获得读写锁执行写操作。这就是使用读写锁时存在的潜在问题:写饥饿。
① 问题描述
读写锁的写饥饿问题(Writer Starvation)是指在使用读写锁时,写线程可能无限期地等待获取写锁,因为读线程持续地获取读锁而不断地推迟写线程的执行。这种情况通常在读操作远多于写操作时出现。
② 解决方案
Linux 提供了可以修改的属性pthread_rwlockattr_t,默认情况下,属性中指定的策略为“读优先”,当写操作阻塞时,读线程依然可以获得读锁,从而在读操作并发较高时导致写饥饿问题。我们可以尝试将策略更改为“写优先”,当写操作阻塞时,读线程无法获取锁,避免了写线程持有锁的时间持续延长,使得写线程获取锁的等待时间显著降低,从而避免写饥饿问题。
pthread_rwlockattr_t
typedef union
{
char __size[__SIZEOF_PTHREAD_RWLOCKATTR_T];
long int __align;
} pthread_rwlockattr_t;
pthread_rwlockattr_init
#include <pthread.h>
/**
* @brief 用所有属性的默认值初始化attr指向的属性对象
*
* @param attr 读写锁属性对象指针
* @return int 成功返回0,失败返回错误码
*/
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
pthread_rwlockattr_destroy
#include <pthread.h>
/**
* @brief销毁读写锁属性对象
*
* @param attr读写锁属性对象指针
* @return int成功返回0,失败返回错误码
*/
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
pthread_rwlockattr_setkind_np
#include <pthread.h>
/**
* @brief将attr指向的属性对象中的"锁类型"属性设置为pref规定的值
*
* @param attr读写锁属性对象指针
* @param pref希望设置的锁类型,可以被设置为以下三种取值的其中一种
* PTHREAD_RWLOCK_PREFER_READER_NP:默认值,读线程拥有更高优先级。当存在阻
塞的写线程时,读线程仍然可以获得读写锁。只要不断有新的读线程,写线程将一直保持"
饥饿"。
* PTHREAD_RWLOCK_PREFER_WRITER_NP:写线程拥有更高优先级。这一选项被glibc忽略。
* PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP:写线程拥有更高优先级,在
当前系统环境下,它是有效的,将锁类型设置为该值以避免写饥饿。
* @return int成功返回0,失败返回非零的错误码
*/
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
创建rwlock_hungry_solved.c:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_rwlock_t rwlock;
int shared_data = 0;
void *lock_reader(void *argv)
{
pthread_rwlock_rdlock(&rwlock);
printf("this is %s, value is %d\n", (char *)argv, shared_data);
sleep(1);
pthread_rwlock_unlock(&rwlock);
}
void *lock_writer(void *argv)
{
pthread_rwlock_wrlock(&rwlock);
int tmp = shared_data + 1;
shared_data = tmp;
printf("this is %s, shared_data++\n", (char *)argv);
pthread_rwlock_unlock(&rwlock);
}
int main()
{
pthread_rwlockattr_t attr;
pthread_rwlockattr_init(&attr);
//设置写优先
pthread_rwlockattr_setkind_np(&attr,PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);
pthread_rwlock_init(&rwlock, &attr);
pthread_rwlockattr_destroy(&attr);
pthread_t writer1, writer2, reader1, reader2, reader3, reader4,
reader5, reader6;
pthread_create(&writer1, NULL, lock_writer, "writer1");
pthread_create(&reader1, NULL, lock_reader, "reader1");
pthread_create(&reader2, NULL, lock_reader, "reader2");
pthread_create(&reader3, NULL, lock_reader, "reader3");
pthread_create(&writer2, NULL, lock_writer, "writer2");
pthread_create(&reader4, NULL, lock_reader, "reader4");
pthread_create(&reader5, NULL, lock_reader, "reader5");
pthread_create(&reader6, NULL, lock_reader, "reader6");
pthread_join(writer1, NULL);
pthread_join(writer2, NULL);
pthread_join(reader1, NULL);
pthread_join(reader2, NULL);
pthread_join(reader3, NULL);
pthread_join(reader4, NULL);
pthread_join(reader5, NULL);
pthread_join(reader6, NULL);
pthread_rwlock_destroy(&rwlock);
}
可以发现,此时的连续六次读操作间夹杂了写操作,不再连续,写操作不必等待所有读操作完成才可以执行。不必长期等待,写饥饿问题已得到解决。
我的理解如下:设置写优先的话,在写操作阻塞时不允许其它的读操作获取读写锁,知道写操作获取锁后再继续执行。解决了写饥饿问题。
自旋锁
在Linux内核中,自旋锁是一种用于多处理器系统中的低级同步机制,主要用于保护非常短的代码段或数据结构,以避免多个处理器同时访问共享资源。自旋锁相对于其他锁的优点是它们在锁被占用时会持续检查锁的状态(即“自旋”),而不是让线程进入休眠。这使得自旋锁在等待时间非常短的情况下非常有效,因为它避免了线程上下文切换的开销。
自旋锁主要用于内核模块或驱动程序中,避免上下文切换的开销。不能在用户空间使用。
条件变量
restrict 关键字
restrict 是一个C99 标准引入的关键字,用于修饰指针,它的作用是告诉编译器,被修饰的指针是编译器所知的唯一一个可以在其作用域内用来访问指针所指向的对象的方法。这样一来,编译器可以放心地执行代码优化,因为不存在其他的别名(即其他指向同一内存区域的指针)会影响到这块内存的状态。
restrict 声明了一种约定,主要目的是允许编译器在生成代码时做出优化假设,而不是在程序的不同部分间强制执行内存访问的规则。程序员需要确保遵守restrict的约定,编译器则依赖这个约定来进行优化。如果restrict约定被违反,可能导致未定义行为。
函数参数使用restrict修饰,相当于约定:函数执行期间,该参数指向的内存区域不会被其它指针修改。
线程间条件切换函数
如果需要两个线程协同工作,可以使用条件变量完成线程切换。查看文档可得:
#include <pthread.h>
/**
* @brief 调用该方法的线程必须持有mutex锁。调用该方法的线程会阻塞并临时释放
mutex 锁,并等待其他线程调用pthread_cond_signal 或pthread_cond_broadcast
唤醒。被唤醒后该线程会尝试重新获取mutex锁。
*
* @param cond 指向条件变量的指针。条件变量用于等待某个条件的发生。通过某一
cond 等待的线程需要通过同一cond的signal唤醒
* @param mutex 与条件变量配合使用的互斥锁的指针。在调用pthread_cond_wait
之前,线程必须已经获得了这个互斥锁。
* @return int 成功时返回0;失败时返回错误码,而非-1。错误码可能包括EINVAL、
EPERM 等,具体取决于错误的性质。
*/
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
/**
* @brief同pthread_cond_wait相似,但是它添加了超时机制。如果在指定的
abstime时间内条件变量没有被触发,函数将返回一个超时错误(ETIMEDOUT)。
*
* @param cond指向条件变量的指针
* @param mutex与条件变量配合使用的互斥锁的指针
* @param abstime指向timespec结构的指针,表示等待条件变量的绝对超时时间。
timespec结构包含秒和纳秒两部分,指定了从某一固定点(如UNIX纪元,1970年1月
1日)开始的时间。
* @return int成功时返回0;如果超时则返回ETIMEDOUT;其他错误情况返回相应的
错误码。
*/
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex, const struct timespec *restrict
abstime);
/**
* @brief唤醒因cond而阻塞的线程,如果有多个线程因为cond阻塞,那么随机唤醒
一个。如果没有线程在等待,这个函数什么也不做。
*
* @param cond指向条件变量的指针
* @return int成功时返回0;失败时返回错误码
*/
int pthread_cond_signal(pthread_cond_t *cond);
/**
* @brief唤醒所有正在等待条件变量cond的线程。如果没有线程在等待,这个函数什
么也不做。
*
* @param cond指向条件变量的指针。
* @return int成功时返回0;失败时返回错误码。
*/
int pthread_cond_broadcast(pthread_cond_t *cond);
说明:
(1)使用条件变量时,通常涉及到一个或多个线程等待“条件变量”代表的条件成立,而另外一些线程在条件成立时触发条件变量。
(2)条件变量的使用必须与互斥锁配合,以保证对共享资源的访问是互斥的。
(3)条件变量提供了一种线程间的通信机制,允许线程以无竞争的方式等待特定条件的发生。
条件变量pthread_cond_t
定义
pthread_cond_t 是一个条件变量,它是线程间同步的另一种机制。与pthread_mutex_t 相同,它也定义在头文件<pthreadtypes.h>中,其声明如下。
typedef union
{
struct __pthread_cond_s __data;
char __size[__SIZEOF_PTHREAD_COND_T];
__extension__ long long int __align;
} pthread_cond_t;
条件变量允许线程挂起执行并释放已持有的互斥锁,等待某个条件变为真。条件变量总是需要与互斥锁一起使用,以避免出现竞态条件。
用途
允许线程等待特定条件的发生。当条件尚未满足时,线程通过条件变量等待,直到其他线程修改条件并通知条件变量。
通知等待中的线程条件已改变,允许它们重新评估条件。
操作
初始化(pthread_cond_init):创建并初始化条件变量。
等待(pthread_cond_wait):在给定的互斥锁上等待条件变量。调用时,线程将释放互斥锁并进入等待状态,直到被唤醒。
定时等待(pthread_cond_timedwait):等待条件变量或直到超过指定的时间。
信号(pthread_cond_signal):唤醒至少一个等待该条件变量的线程。
广播(pthread_cond_broadcast):唤醒所有等待该条件变量的线程。
销毁(pthread_cond_destroy):清理条件变量资源。
PTHREAD_COND_INITIALIZER
说明及用法
PTHREAD_COND_INITIALIZER 是 POSIX 线程(Pthreads)库中定义的一个宏,用于在声明时静态初始化条件变量(pthread_cond_t类型的变量)。它提供了一种简单、便捷的方式来初始化条件变量,无需调用初始化函数pthread_cond_init。
使用PTHREAD_COND_INITIALIZER 可以让条件变量在程序启动时即处于可用状态,这对于全局或静态分配的条件变量尤其有用。
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
注意事项
使用PTHREAD_COND_INITIALIZER 静态初始化的条件变量通常不需要调用pthread_cond_destroy 来销毁。但是,如果条件变量在程序执行期间被重新初始化(通过pthread_cond_init),那么在不再需要时应使用pthread_cond_destroy进行清理。
PTHREAD_COND_INITIALIZER只适用于静态或全局变量的初始化。对于动态分配的条件变量(例如,通过malloc分配的条件变量),应使用pthread_cond_init函数进行初始化。
PTHREAD_COND_INITIALIZER提供的是条件变量的默认属性。如果需要自定义条件变量的属性(例如,改变其pshared属性以支持进程间同步),则需要使用pthread_cond_init 和 pthread_condattr_t 类型的属性对象。
范例程序
创建condition_var.c:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int count = 0;
//初始化互斥锁
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//初始化条件变量
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
//生产者 向Buffer中写数据
void *producer(void *arg){
//使用共同的变量 使用互斥锁 首先获取锁
pthread_mutex_lock(&mutex);
int item = 1;
while(1){
//如果缓冲区写满 使用条件变量暂停当前线程
if(count == BUFFER_SIZE){
//暂停线程
pthread_cond_wait(&cond,&mutex);
}
//缓冲区没有满,写入数据
buffer[count++] = item ++;
printf("白月光发送了一个幸运数字%d\n",buffer[count-1]);
//唤醒消费者
pthread_cond_signal(&cond);
}
//最后释放锁
pthread_mutex_unlock(&mutex);
}
//生产者 向Buffer中写数据
void *consumer(void *arg){
//使用共同的变量 使用互斥锁 首先获取锁
pthread_mutex_lock(&mutex);
while(1){
//缓存中没有消息可读
if(count == 0){
//暂停线程
pthread_cond_wait(&cond,&mutex);
}
//缓冲区有数据,读出数据
printf("我收到白月光的新云数字为%d\n",buffer[--count]);
//唤醒生产者
pthread_cond_signal(&cond);
}
//最后释放锁
pthread_mutex_unlock(&mutex);
}
int main(){
//创建两个线程 读写线程
pthread_t producer_thread,consumer_thread;
//创建生产者线程
pthread_create(&producer_thread,NULL,producer,NULL);
//创建消费者线程
pthread_create(&consumer_thread,NULL,consumer,NULL);
pthread_join(producer_thread, NULL);
pthread_join(consumer_thread, NULL);
return 0;
}
注意:我的理解如下:
1.调用pthread_cond_wait后,当前线程被阻塞,释放锁,那么另一个进程就会占有锁
2.另一个线程调用pthread_cond_signal,被阻塞的线程被唤醒,但是锁依然在被占有的进程中,只有当该进程释放锁之后,两个进程再重新争抢锁
3.如果释放锁在循环内部,那么消费者只要读取一次数据后就会释放锁,那么两个进程就会重新开始争抢锁,那么两个进程的执行顺序就是不确定的
4.如果锁在循环外部即不会主动执行释放锁,那么消费者只有读取完5次数据后,调用pthread_cond_wait时,生产者才能够重新获得锁,此时就可以保证生产者与消费者5次5次的交替执行。
信号量
信号量相关概念
信号量(Semaphore)是一种广泛使用的同步机制,用于控制对共享资源的访问,主要在操作系统和并发编程领域中得到应用。信号量是由Edsger Dijkstra在1960年代提出的,用来解决多个进程或线程间的同步与互斥问题。
与共享存储等不同,在Linux中,信号量是用来协调进程或线程的执行的,并不承担传输数据的职责。
基本概念
信号量本质上是一个非负整数变量,可以被用来控制对共享资源的访问。它主要用于两种目的:互斥和同步。
(1)互斥(Mutex):确保多个进程或线程不会同时访问临界区(即访问共享资源的代码区域)。
(2)同步(Synchronization):协调多个进程或线程的执行顺序,确保它们按照一定的顺序执行。
基于用途的分类
基于不同的目的,信号量可以分为两类:用于实现互斥的“二进制信号量”和用于同步的“计数信号量”。
(1)二进制信号量(或称作互斥锁):其值只能是0或1,主要用于实现互斥,即一次只允许一个线程进入临界区。通常用于控制共享资源的访问,避免竞态条件的产生。
(2)计数信号量:其值可以是任意非负整数,表示可用资源的数量。计数信号量允许多个线程根据可用资源的数量进入临界区。通常用于控制不同进程或线程执行的顺序,如消费者必须在生产者发送数据后才可以消费。
基于名称的分类
在Linux 中,根据是否具有唯一的名称,分为有名信号量(named semaphore)和无名信号量(unnamed semaphore)。这两种信号量特性有所不同:
(1)无名信号量
无名信号量不是通过名称标识,而是直接通过sem_t结构的内存位置标识。无名信号量在使用前需要初始化,在不再需要时应该销毁。它们不需要像有名信号量那样进行创建和链接,因此设置起来更快,运行效率也更高。
(2)有名信号量
有名信号量在系统范围内是可见的,可以在任意进程之间进行通信。它们通过名字唯一标识,这使得不同的进程可以通过这个名字访问同一个信号量对象。在当前Linux系统中,有名信号量在临时文件系统中的对应文件位于/dev/shm目录下,创建它们时可以像普通文件一样设置权限模式,限制不同用户的访问权限。
操作
信号量主要提供了两个操作:P操作和V操作。
(1)P操作(Proberen,尝试):也称为等待操作(wait),用于减少信号量的值。如果信号量的值大于0,它就减1并继续执行;如果信号量的值为0,则进程或线程阻塞,直到信号量的值变为非零。
(2)V操作(Verhogen,增加):也称为信号操作(signal),用于增加信号量的值。如果有其他进程或线程因信号量的值为0而阻塞,这个操作可能会唤醒它们。
无名信号量
无名信号量和有名信号量都可以用作二进制信号量和计数信号量。
相关函数
sem_init()
#include <semaphore.h>
/**
* @brief在sem指向的地址初始化一个无名信号量。
*
* @param sem信号量地址
* @param pshared指明信号量是线程间共享还是进程间共享的
* 0:信号量是线程间共享的,应该被置于所有线程均可见的地址(如,全局变量或在堆中动态分配的变量)
* 非0:信号量是进程间共享的,应该被置于共享内存区域,任何进程只要能访问共享内存区域,即可操作进程间
* 共享的信号量
* @param value信号量的初始值
* @return int成功返回0,失败返回-1,同时errno被设置以记录错误信息
*/
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem_destroy()
#include <semaphore.h>
/**
* @brief销毁sem指向的无名信号量
*
* @param sem无名信号量
* @return int成功返回0,失败返回-1,并设置errno指示错误原因
*/
int sem_destroy(sem_t *sem);
sem_post()
#include <semaphore.h>
/**
* @brief将sem指向的信号量加一,如果信号量从0变为1,且其他进程或线程因信号
量而阻塞,则阻塞的进程或线程会被唤醒并获取信号量,然后继续执行。POSIX标准并未
明确定义唤醒策略,具体唤醒的是哪个进程或线程取决于操作系统的调度策略。
*
* @param sem信号量指针
* @return int成功返回0,失败则信号量的值未被修改,返回-1,并设置errno以指明错误原因
*/
int sem_post(sem_t *sem);
sem_wait()
#include <semaphore.h>
/**
* @brief将sem指向的信号量减一。如果信号量的值大于0,函数可以执行减一操作,
然后立即返回,调用线程继续执行。如果当前信号量的值是0,则调用阻塞直至信号量的
值大于0,或信号处理函数打断当前调用。
*
* @param sem信号量指针
* @return int成功返回0,失败则信号量的值保持不变,返回-1,并设置errno以指
明错误原因
*/
int sem_wait(sem_t *sem);
time_t
本质上就是long int即long类型。
time()
#include <time.h>
/**
* @brief返回以秒为单位的UNIX时间戳
*
* @param tloc记录时间的指针,如果不为NULL,则当前的UNIX秒级时间戳也会存在
tloc指向的位置,否则不会存储。
* @return time_t成功则返回以秒为单位的UNIX时间戳,失败则返回(time_t)-1
*/
time_t time(time_t *tloc);
rand()
#include <stdlib.h>
/**
* @brief返回一个0-RAND_MAX之间的伪随机数。当前机器环境下,RAND_MAX为
int类型的最大值
*
* @return int伪随机数
*/
int rand(void);
srand()
#include <stdlib.h>
/**
* @brief将seed设置为rand()生成随机数时使用的随机种子,如果没有设置随机种
子,rand()会自动将1作为随机种子
*
* @param seed随机种子
*/
void srand(unsigned int seed);
作为二进制信号量用于线程间通信
创建unnamed_sem_bin_thread_condition.c:
#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <pthread.h>
int shard_num = 0;
sem_t unnamed_sem;
void *plusOne(void *argv){
sem_wait(&unnamed_sem);
int tmp = shard_num +1;
shard_num = tmp;
sem_post(&unnamed_sem);
}
int main() {
//提前初始化信号量
sem_init(&unnamed_sem,0,1);
pthread_t tid[10000];
for (int i = 0; i < 10000; i++) {
pthread_create(tid + i, NULL, plusOne, NULL);
}
for (int i = 0; i < 10000; i++) {
pthread_join(tid[i], NULL);
}
printf("shard_num is %d\n", shard_num);
sem_destroy(&unnamed_sem);
return 0;
}
作为二进制信号量用于进程间通信
需要注意的是,线程比进程的资源共享程度更高,可以用于进程间通信的方式,通常也可以用于线程间通信。
创建unnamed_sem_bin_process_condition.c:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <semaphore.h>
int main() {
char *shm_value_name = "unnamed_sem_shm_value";
//使用共享内存创建信号量
char *shm_sem_name = "unnamed_sem_shm_sem";
//创建内存共享对象
int value_fd = shm_open(shm_value_name, O_CREAT | O_RDWR, 0666);
int sem_fd = shm_open(shm_sem_name, O_CREAT | O_RDWR, 0666);
//调整内存共享对象的大小
ftruncate(value_fd, sizeof(int));
ftruncate(sem_fd, sizeof(sem_t));
//将内存共享对象映射到共享内存区域
int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,MAP_SHARED, value_fd, 0);
//将共享内存的信号量映射到共享内存区域
sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE,MAP_SHARED, sem_fd, 0);
//初始化共享变量的值
*value = 0;
//初始化信号量的值
sem_init(sem,1,1);
int pid = fork();
if (pid > 0) {
sem_wait(sem);
int tmp = *value + 1;
sleep(1);
*value = tmp;
sem_post(sem);
//等待子进程执行完毕
waitpid(pid, NULL, 0);
printf("this is father, child finished\n");
printf("the final value is %d\n", *value);
} else if (pid == 0) {
sem_wait(sem);
int tmp = *value + 1;
sleep(1);
*value = tmp;
sem_post(sem);
} else {
perror("fork");
}
//父进程执行到这里,子进程已执行完毕,可以销毁信号量
if (pid > 0)
{
if (sem_destroy(sem) ==-1)
{
perror("sem_destory");
}
}
//无论父子进程都应该解除共享内存的映射,并关闭共享对象的文件描述符
if (munmap(sem, sizeof(sem)) ==-1)
{
perror("munmap sem");
}
if (munmap(value, sizeof(int)) ==-1)
{
perror("munmap value");
}
if (close(sem_fd) ==-1)
{
perror("close sem");
}
if (close(value_fd) ==-1)
{
perror("close value");
}
//如果调用时别的进程仍在使用共享对象,则等待所有进程释放资源后,才会销毁相关资源。
// shm_unlink只能调用一次,这里在父进程中调用shm_unlink
if (pid > 0)
{
if (shm_unlink(shm_sem_name) ==-1)
{
perror("father shm_unlink shm_sem_name");
}
if (shm_unlink(shm_value_name) ==-1)
{
perror("father shm_unlink shm_value_name");
}
}
return 0;
}
注意
无名信号量被用于进程间通信时,需要注意两点:
①sem_init()的第二个参数应设置为非零值,来告诉操作系统内核,这个信号量是用来进程间通信的,如果设置为0,则一个进程通过sem_post()释放的信号量无法被其它进程获取,会导致程序卡死。
②信号量必须置于共享内存区域,以确保多个进程都可以访问,否则每个进程各自管理自己的信号量,后者并没有起到进程间通信的作用。
此处为便于讲解,只给出了父子进程间通信的示例,但由于共享内存对象可以被任意进程访问,因此,无名信号量实际上可以用于任意进程间的通信,而不仅限于父子进程。在非父子进程通信时,共享资源的初始化和释放要格外注意,必须按照合理的顺序进行
作为计数信号量用于线程间通信
计数信号量主要是用于控制进程或线程执行顺序的。
创建unnamed_sem_count_thread.c:
#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <stdlib.h>
#include <time.h>
#include <pthread.h>
sem_t *full;
sem_t *empty;
int shard_num;
int rand_num()
{
srand(time(NULL));
return rand();
}
void *producer(void *argv)
{
for (int i = 0; i < 5; i++)
{
sem_wait(empty);
printf("\n==========>第%d轮数据传输<=========\n\n", i + 1);
sleep(1);
shard_num = rand_num();
printf("producer has sent data\n");
sem_post(full);
}
}
void *consumer(void *argv)
{
for (int i = 0; i < 5; i++)
{
sem_wait(full);
printf("consumer has read data\n");
printf("the shard_num is %d\n", shard_num);
sleep(1);
sem_post(empty);
}
}
int main()
{
full = malloc(sizeof(sem_t));
empty = malloc(sizeof(sem_t));
sem_init(empty, 0, 1);
sem_init(full, 0, 0);
pthread_t producer_id, consumer_id;
pthread_create(&producer_id, NULL, producer, NULL);
pthread_create(&consumer_id, NULL, consumer, NULL);
pthread_join(producer_id, NULL);
pthread_join(consumer_id, NULL);
sem_destroy(empty);
sem_destroy(full);
return 0;
}
代码逻辑分析:
在上述例程中,我们定义了两个信号量:full和empty,full表示当前的缓冲区(在本例中为shared_num,实质上只能存储一个int类型的数据)是否已满,empty表示缓冲区是否已空。我们启动了一个消费者线程consumer和生产者线程producer,前者用于从缓冲区读取数据(消费),后者用于向缓冲区写入数据(生产)。
① 生产者的逻辑:首先等待缓冲区为空,然后写入数据,最后通过sem_post告诉消费者,缓冲区已满,可以消费数据。
② 消费者的逻辑:首先等待缓冲区满,然后消费数据,最后通过sem_post告诉生产者,缓冲区已空,可以生产数据。
需要注意的是,初始时缓冲区是空的,因此,empty信号量的初值应为1,而full应为0,二者都是用于线程间通信的,sem_init()函数的第二个参数都应该是0。
上述例程通过两个信号量控制生产者和消费者的执行顺序:
① 消费者必须在生产者发送数据后方可消费。
② 除首次发送外,生产者必须等待消费者读取数据后方可发送数据。
与二进制信号量的区别与联系
上述案例中,信号量的取值仍在0和1之间变动,但这并不意味着本例中的也是二进制信号量。二进制信号量和计数信号量的划分更多地是从控制效果来说的:二进制信号量起到了互斥锁的作用,当多个进程或线程访问共享资源时,确保同一时刻只有一个进程或线程进入了临界区,起到了“互斥”的作用;而计数信号量起到了“控制顺序”的作用,明确了“谁先执行”、“谁后执行”。很显然,本例是通过信号量控制了线程执行的先后顺序,属于计数信号量。
计数信号量和二进制信号量的划分不能只看信号量值的波动范围。
本例只展示了一个生产者和一个消费者在缓冲区为1时的协同工作,如果我们增加生产者和消费者的数量,信号量的取值范围自然就不再是0和1了。
作为计数信号量用于进程间通信
创建unnamed_sem_count_process.c:
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
char *shm_name = "unnamed_sem_shm";
//创建内存共享对象
int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
//调整内存共享对象的大小
ftruncate(fd, sizeof(sem_t));
//将内存共享对象映射到共享内存区域
sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE,MAP_SHARED, fd, 0);
//初始化信号量
sem_init(sem, 1, 0);
int pid = fork();
if (pid > 0)
{
sem_wait(sem);
printf("this is father\n");
//父进程等待子进程退出并回收资源
waitpid(pid, NULL, 0);
}
else if (pid == 0)
{
sleep(1);
printf("this is son\n");
sem_post(sem);
}
else
{
perror("fork");
}
//父进程执行到此处,子进程已执行完毕,可以销毁信号量
//子进程执行到此处,父进程仍在等待信号量,此时销毁会导致未定义行为
//只有父进程中应该销毁信号量
if (pid > 0)
{
if (sem_destroy(sem) ==-1)
{
perror("father sem_destroy");
}
}
//父子进程都应该解除映射,关闭文件描述符
if (munmap(sem, sizeof(sem)) ==-1)
{
perror("munmap");
}
if (close(fd) ==-1)
{
perror("close");
}
// shm_unlink只能调用一次,只在父进程中调用
if (pid > 0)
{
if (shm_unlink(shm_name) ==-1)
{
perror("father shm_unlink");
}
}
return 0;
}
有名信号量
有名信号量的名称形如/somename,是一个以斜线(/)打头,\0字符结尾的字符串,打头的斜线之后可以有若干字符但不能再出现斜线,长度上限为NAME_MAX-4(即251)。不同的进程可以通过相同的信号量名称访问同一个信号量。
有名信号量通常用于进程间通信,这是因为线程间通信可以有更高效快捷的方式(全局变量等),不必“杀鸡用牛刀”。但要注意的是,正如上文提到的,可以用于进程间通信的方式通常也可以用于线程间通信。
有名信号量可以用于任意进程间的通信,为了简化程序,此处以父子进程间通信为例
相关函数
sem_open()
#include <fcntl.h> /* For O_* constants */
#include <sys/stat.h> /* For mode constants */
#include <semaphore.h>
/**
* @brief创建或打开一个已存在的POSIX有名信号量。
*
* @param name信号量的名称
* @param oflag标记位,控制调用函数的行为。是一个或多个值或操作的结果。常用的是O_CREAT。
* O_CREAT:如果信号量不存在则创建,指定了这个标记,必须提供mode和value
* @param mode有名信号量在临时文件系统中对应文件的权限。需要注意的是,应确保每个需要访问当前有名信
* 号量的进程都可以获得读写权限。
* @param value信号量的初始值
* @return sem_t*成功则返回创建的有名信号量的地址,失败则返回SEM_FAILED,同时设置errno以指出错误
* 原因
*/
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
/**
* @brief见四个参数的sem_open()
*
* @param name同上
* @param oflag同上
* @return sem_t*同上
*/
sem_t *sem_open(const char *name, int oflag);
sem_close()
#include <semaphore.h>
/**
* @brief关闭对于sem指向的有名信号量的引用,每个打开了有名信号量的进程在结
束时都应该关闭引用
*
* @param sem有名信号量指针
* @return int成功返回0,失败返回-1,并设置errno以指明错误原因
*/
int sem_close(sem_t *sem);
sem_unlink()
#include <semaphore.h>
/**
* @brief移除内存中的有名信号量对象,/dev/shm下的有名信号量文件会被清除。当
没有任何进程引用该对象时才会执行清除操作。只应该执行一次。
*
* @param name有名信号量的名称
* @return int成功返回0,失败返回-1,并设置errno以指明错误原因
*/
int sem_unlink(const char *name);
有名信号量用作二进制信号量
创建named_sem_bin.c:
#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
char *sem_name = "/named_sem";
char *shm_name = "/named_sem_shm";
//初始化有名信号量
sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 1);
//初始化内存共享对象
int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
//调整内存共享对象的大小
ftruncate(fd, sizeof(int));
//将内存共享对象映射到内存空间
int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,MAP_SHARED, fd, 0);
//初始化共享变量指针指向位置的值
*value = 0;
pid_t pid = fork();
if (pid < 0)
{
perror("fork");
}
if(pid == 0)
{
sem_wait(sem);
int tmp = *value + 1;
sleep(1);
*value = tmp;
sem_post(sem);
}
if (pid > 0)
{
sem_wait(sem);
int tmp = *value + 1;
sleep(1);
*value = tmp;
sem_post(sem);
waitpid(pid, NULL, 0);
printf("子进程执行结束,value = %d\n", *value);
}
//父子进程都解除内存共享对象的映射,并关闭相应的文件描述符
if(munmap(value, sizeof(int)) == -1){
perror("munmap value");
}
if(close(fd) == -1){
perror("close vlaue");
}
//每个进程都应该在使用完毕后关闭对信号量的连接
if(sem_close(sem) == -1){
perror("close vlaue");
}
//只有父进程应该释放内存共享对象
if (pid > 0)
{
if (shm_unlink(shm_name) ==-1)
{
perror("shm_unlink");
}
if (sem_unlink(sem_name) ==-1)
{
perror("shm_unlink");
}
}
return 0;
}
有名信号量用作计数信号量
创建named_sem_count.c:
#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
char *sem_name = "/named_sem";
//初始化有名信号量
sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 0);
pid_t pid = fork();
if (pid > 0) {
sem_wait(sem);
printf("this is father\n");
//等待子进程执行完毕
waitpid(pid, NULL, 0);
//释放引用
sem_close(sem);
//释放有名信号量
if(sem_unlink(sem_name) ==-1) {
perror("sem_unlink");
}
} else if(pid == 0) {
sleep(1);
printf("this is son\n");
sem_post(sem);
//释放引用
sem_close(sem);
} else
{
perror("fork");
}
return 0;
}
有名信号量在文件系统的表示
后查看/dev/shm目录
我们创建了名为/named_sem的信号量,将信号量名称去掉’/'后补充’sem.'前缀得到的字符串就是它在tmpfs中的对应文件名。
关于信号量的总结
(1)可用于进程间通信的方式通常都可以用于线程间通信。
(2)无名信号量和有名信号量均可用于进程间通信,有名信号量是通过唯一的信号量名称在操作系统中唯一标识的。无名信号量用于进程间通信时必须将信号量存储在进程间可以共享的内存区域,作为内存地址直接在进程间共享。而内存区域的共享是通过内存共享对象的唯一名称来实现的。
(3)无名信号量和有名信号量都可以作为二进制信号量和计数信号量使用。
(4)二进制信号量和计数信号量的区别在于前者起到了互斥锁的作用,而后者起到了控制进程或线程执行顺序的作用。而不仅仅是信号量取值范围的差异。
(5)信号量是用来协调进程或线程协同工作的,本身并不用于传输数据。
(6)通常,从编码复杂度和效率的角度考虑,进程间通信使用有名信号量,线程间通信使用无名信号量。
(7)信号量用于跨进程通信时,要格外注意共享资源的创建和释放顺序,避免资源泄露或在不恰当的时机释放资源从而导致未定义行为。
(8)在生产环境的开发中,对于关键的步骤应当补充充分的错误处理,以便在错误发生时及时告警和响应。包括根据函数的返回值进行检查,结合使用perror或类似机制及时输出错误日志,以便快速排查和解决问题。此外,应确保适当释放资源以避免资源泄露。本文省略了这些步骤,这是为了使代码结构更加清晰以降低学习成本。
线程池
简介
线程池是一种用于管理和重用多个线程的设计模式。它通过维护一个线程池(线程的集合),可以有效地处理并发任务而无需每次都创建和销毁线程。这种方法可以减少线程创建和销毁的开销,提高性能和资源利用率。
Glib 库
官方文档链接:https://docs.gtk.org/glib/
GLib 是 GNOME 项目的一部分,是一个通用的底层库,提供数据结构、实用工具和系统相关的功能。它最初是为了 GIMP(GNU Image Manipulation Program)而开发的,但现在已经成为许多其他应用程序的基础。
Glib 库线程池工作流程
(1)线程池创建:首先创建一个线程池,指定任务函数和其他参数。线程池会创建一定数量的线程,这些线程进入等待状态,准备执行任务,或在提交任务后才创建线程(取决于配置)。线程池中的所有任务执行的都是同一个任务函数。
(2)任务队列:线程池维护一个任务队列。当我们向线程池提交任务时,任务会被放入这个队列中。实际上,放入任务队列的是我们在提交任务时传递的任务数据。
(3)线程执行任务:线程池中的线程从任务队列中取出任务数据,然后调用任务函数,执行任务。执行完成后,线程不会退出,而是继续从任务队列中取下一个任务执行。如果没有待执行的任务,线程通常在等待一段时间后被回收(取决于具体的配置)。
相关数据类型
GFunc
// 此处的 data 是在启动任务时,传递给每个任务的,而 user_data 是在创建线程池时传入的共享数据,
// 对于每个任务都是一样的
typedef void (*GFunc)(gpointer data, gpointer user_data);
gpointer
typedef void *gpointer;
gint
typedef int gint;
gboolean
typedef gint gboolean;
#define TRUE 1
#define FALSE 0
Gerror
/**
* @brief 记录已发生的错误信息
* domain: 表示错误的域(或命名空间)。GQuark 是一个无符号整数,用于唯一标识
一个字符串。它通常用来区分不同的错误类别,例如文件操作错误、网络错误等。
* code: 表示错误的代码。这是一个整型值,用于具体描述错误。每个域中的错误代码
应该是唯一的,并且通常有一组预定义的错误代码。
* gchar: 表示错误消息。gchar 是 char 的别名,用于在 GLib 中统一字符类型。
message 是一个字符串指针,包含了详细的错误描述信息。
*/
struct GError {
GQuark domain;
gint code;
gchar *message;
}
GThreadPool
/**
* @brief线程池对象
* func:线程池中执行的任务
* user_data:线程池中共享的用户数据指针,会在每个任务函数调用时传递给任务函数。
* exclusive:标记当前线程池是否独占线程
*/
struct GThreadPool {
GFunc func;
gpointer user_data;
gboolean exclusive;
}
相关函数
g_thread_pool_new
/**
* @brief创建新的线程池
*
* @param func池中线程执行的函数
* @param user_data传递给func的数据,可以为NULL,这里的user_data最终会
被存储在GThreadPool结构体的user_data属性中
* @param max_threads线程池容量,即当前线程池中可以同时运行的线程数。-1表示
没有限制
* @param exclusive独占标记位。决定当前的线程池独占所有的线程还是与其它线程池共享这些线程。
* 取值可以是TRUE或FALSE
* TRUE:立即启动数量为max_threads的线程,且启动的线程只能被当前线程池使用
* FALSE:只有在需要时,即需要执行任务时才创建线程,且线程可以被多个非独享资
源的线程池共用
* @param error用于报告错误信息,可以是NULL,表示忽略错误
* @return GThreadPool*线程池实例指针。无论是否发生错误,都会返回有效的线程
池
*/
GThreadPool *g_thread_pool_new(
GFunc func,
gpointer user_data,
gint max_threads,
gboolean exclusive,
GError **error);
g_thread_pool_push
/**
* @brief 向pool指向的线程池实例添加数据,这一行为实际上会向任务队列添加新的任务。当存在可用线程时
* 任务立即执行,否则任务数据会一直待在队列中,直至腾出可用线程执行任务
*
* @param pool指向线程池实例的指针
* @param data传递给每个任务的独享数据
* @param error错误信息
* @return gboolean成功返回TRUE,失败返回FALSE
*/
gboolean g_thread_pool_push(
GThreadPool *pool,
gpointer data,
GError **error);
g_thread_pool_free
/**
* @brief 释放为pool指向的线程池分配的所有资源
*
* @param pool线程池指针
* @param immediate是否立即释放线程池
* TRUE:立即释放所有资源,未处理的数据不被处理
* FALSE:在最后一个任务执行完毕之前,线程池不会被释放
*需要注意的是:执行任务时,线程池的任何一个线程都不会被打断。无论这个参数是
何取值,都可以保证至少线程池释放前正在运行的线程可以完成它们的任务。
* @param wait_当前函数是否阻塞等待所有任务完成
* TRUE:所有需要处理的任务执行完毕当前函数才会返回
* FALSE:当前函数立即返回
*/
void g_thread_pool_free (
GThreadPool* pool,
gboolean immediate,
gboolean wait_
);
测试例程
创建thread_pool_test.c:
#include <glib.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void task_func(gpointer data,gpointer user_data){
int task_num = *(int *) data;
free(data);
printf("开始执行%d任务\n",task_num);
sleep(task_num);
printf("%d任务完成\n",task_num);
}
int main(){
//创建线程池
GThreadPool *pool = g_thread_pool_new(task_func,NULL,5,TRUE,NULL);
//向线程池中添加任务
for(int i = 0;i < 10;i++){
//每一个提交任务的编号
int *tmp = malloc(sizeof(int));
*tmp = i + 1;
g_thread_pool_push(pool,tmp,NULL);
}
g_thread_pool_free(pool,FALSE,TRUE);
printf("所有的任务都完成了\n");
return 0;
}
注意:每次添加任务时,传入的变量tmp需要动态分配,不然会导致多个线程访问同一个地址,值是不确定的。
向makefile中添加标志
thread_pool_test: thread_pool_test.c
-$(CC)-o $@ $^ `pkg-config--cflags--libs glib-2.0`
-./$@
-rm ./$@