Linux pthead库的使用

pthread库提供了多线程相关的功能,相对比较底层。这里对相关内容做一些记录。

PS : NVIDIA Jetson-utils官方在pthread的基础上进行了简单的封装,写在Thread、Mutex、Event中,学习时可以参考他的实现

使用时需要#include <phread.h> ,头文件可以在/usr/include/下找到。

一、 线程创建、执行与结束

这一块的核心包括以下内容

extern int pthread_create (pthread_t *__restrict __newthread,
			   const pthread_attr_t *__restrict __attr,
			   void *(*__start_routine) (void *),
			   void *__restrict __arg) __THROWNL __nonnull ((1, 3));
extern void pthread_exit (void *__retval) __attribute__ ((__noreturn__));
extern int pthread_join (pthread_t __th, void **__thread_return);
extern int pthread_detach (pthread_t __th) __THROW;
extern int pthread_attr_init (pthread_attr_t *__attr) __THROW __nonnull ((1));

1. pthread_create

pthread_create函数有四个输入参数,第一个参数是你将要创建的线程的指针,第二个参数可以用于修改线程属性,第三个是线程入口函数的地址,第四个参数是主线程给子线程传递的运行参数。下面有一个简单写法示例(不完整),假设在一个实时性较高的图像处理项目里,我们要隔几秒保存一次当前帧的图片,而存图函数saveImage运行非常慢,如果直接把保存功能串行在代码里,则会影响帧率的稳定性,所以我们选择开一个子线程来做存图。

struct SaveThreadArgs
{
	void* image_data;
	int frame_num;
};

static void* SaveFunc(void* _args)
{
    // 入口函数第一句就要进行解包,把void*强转回结构体指针
	SaveThreadArgs* args = (SaveThreadArgs*)_args;
	char file_name[50];
	snprintf(file_name, sizeof(filename), "./%08d.jpg", args->framenum);
	saveImage(file_name, args->image_data);
}

int main()
{
    // ... ...
    // 交给子线程的参数不止一个,需用结构体包装,传入pthread_create时强转为void*
    SaveThreadArgs save_args; 
    save_args.image_data = my_data;
    save_args.frame_num = num;

    pthread_t save_thread;
    if(pthread_create(&save_thread, NULL, SaveFunc, (void*)&save_args) != 0)
        return -1;
    // ... ...
    return 0;
}

我们从最后一行看起,我们要调pthread_create函数,则要搞定他的四个输入参数。1)定义一个pthread_t 类型的变量,第一个参数传他的地址即可。2)可以不修改线程属性,第二个参数传NULL。3)需要一个线程入口函数,也就是你指挥这个子线程去做的事。4)把存图功能需要的参数传入。pthread_create创建成功会返回0,这里需要判断一下。

关于第三个参数和第四个参数,这里需要进一步解释一下。入口函数的格式必须是这样,void* SaveFunc(void* _args)。前面的void*规定函数返回无类型指针,参数列表中的void*规定无论你的参数是什么类型,传进来时都要强制类型转换成void*再传入。线程成功创建后会把(void*)&save_args交给void* _args,然后子线程开始执行SaveFunc。

一般我们需要交给子线程的参数不止一个,比如这里的图像数据和图像的序号。这时候就需要我们自定义一个结构体SaveThreadArgs来包装多个参数。pthread_create之前要把结构体强转为void*,而入口函数执行的第一步就需要把其中内容解包,把void*强转回结构体指针,后面就能取到输入的参数了。

2. pthread_join 和 pthread_exit

子线程执行的过程中可以被强行终止,比如我们可以修改线程入口函数。

static void* SaveFunc(void* _args)
{
	SaveThreadArgs* args = (SaveThreadArgs*)_args;
    if(args->image_data == NULL) // 指向图片数据的指针是空的
        pthread_exit("ERROR!"); // 线程退出
	char file_name[50];
	snprintf(file_name, sizeof(filename), "./%08d.jpg", args->framenum);
	saveImage(file_name, args->image_data);
}

pthread_exit填入的参数线程退出时返回的数据,它是void*的指针,可以指向任何类型,但不可以指向函数内部的一个局部变量,否则会崩溃(很好理解,线程跑完了资源释放了,指针指向的内容也没了)。如果线程不需要返回任何数据,这里填NULL。

pthread_join是需要在主线程中调用的,作用是等待子线程运行结束,如果主线程没有等子线程跑完,自己结束了,就程序会崩溃。所以我们必须在主线程退出前写下pthread_join。

int main()
{
    // ... ...
    pthread_t save_thread;
    if(pthread_create(&save_thread, NULL, SaveFunc, (void*)&save_args) != 0)
        return -1;
    // ... ...
    void* thread_result;
    pthread_join(save_thread, &thread_result); // 等子线程运行完,并接收pthread_exit返回的数据
    printf("%s", (char*)thread_result);
    return 0;
}

pthread_join的第一个参数是要等的子线程的标识符,刚才提到pthread_exit可以在线程退出时返回一个void*,pthread_join的第二个参数就是用于接收返回的数据,同样,需要强转。

3. pthread_detach

pthread_detach进行线程的分离。线程的分离状态只有两个取值,detached和joinable。初始化的时候默认是joinable。线程的分离状态决定一个线程以什么样的方式来终止自己,如果是joinable,则主线程必须调pthread_join等待它结束,此后子线程正式终止,释放资源。如果是detached,则子线程完全分离出去,就算主线程结束后它还没跑完,它也会自己照顾好自己。被分离出去的线程不需要调用pthread_join。使用下面的写法,就算主程序提前结束,存图线程也会完成他的任务,把图存下。

int main()
{
    SaveThreadArgs save_args; 
    save_args.image_data = my_data;
    save_args.frame_num = num;

    pthread_t save_thread;
    if(pthread_create(&save_thread, NULL, SaveFunc, (void*)&save_args) != 0)
        return -1;
    pthread_detach(save_thread);
    // ... ...
    return 0;
}

4. pthread_attr_t

pthread_attr_t是一个类型,用来设置线程的属性,需要先调用pthread_attr_init,设置某些属性后,把这个变量传进pthread_create的第二个位置。比如可以调用pthread_attr_getdetachstate设置分离状态。

extern int pthread_attr_setdetachstate (pthread_attr_t *__attr,
					int __detachstate)
// 第二个参数可选 PTHREAD_CREATE_DETACHED 或 PTHREAD _CREATE_JOINABLE

二、互斥锁

多线程同时读写临界资源的时候会导致结果与预期不一致(比如四个线程同时进行i++)。控制线程访问临界资源的代码叫做临界区,我们在进入临界区前获取互斥锁,离开后释放互斥锁,就能防止临界资源被多个线程同时读写。同一时间只允许一个线程获得互斥锁,其他线程获取互斥锁失败时,则进入阻塞态,让出cpu,一旦锁被释放,所有等待的线程都变为就绪态,进行线程抢占,只有一个线程抢占到互斥锁,进入临界区,其余线程继续阻塞。下面给出了基本用法。

pthread_mutex_t my_mutex;
pthread_mutex_init(&my_mutex, NULL); // 使用默认参数初始化
pthread_mutex_lock(&my_mutex); // 加锁
// ... 读写临界资源
pthread_mutex_unlock(&my_mutex); // 解锁

// pthread_mutex_trylock以非阻塞模式获取锁
// 即,若锁未被占,则获取它并返回0,否则直接返回非0值,不等待锁被释放
if(pthread_mutex_trylock(&my_mutex) == 0)
{
    // ... 读写临界资源
    pthread_mutex_unlock(&my_mutex);
} 

pthread_mutex_destroy(&my_mutex);

三、读写锁

读写锁是更高级的互斥锁,它允许多个读者线程同时持有读锁,但只允许一个写者线程持有写锁。如果写锁锁定时,不允许其他线程再获取读锁,读锁锁定时,不允许其他线程获取写锁。下面是对pthread提供的读写锁的一个简单封装。

class RWMutex
{
public:
	inline RWMutex()		{ pthread_rwlock_init(&mID, NULL); }
	inline ~RWMutex()		{ pthread_rwlock_destroy(&mID); }
	inline void RLock()		{ pthread_rwlock_rdlock(&mID); }
	inline void WLock()		{ pthread_rwlock_wrlock(&mID); }
	inline void Unlock()	{ pthread_rwlock_unlock(&mID); }
	inline bool AttempRLock()	{ return (pthread_rwlock_tryrdlock(&mID) == 0); }
	inline bool AttempWLock()	{ return (pthread_rwlock_trywrlock(&mID) == 0); }
private:
	pthread_rwlock_t mID;
};

四、自旋锁

自旋锁与互斥锁类似,唯一的区别就是,当一个线程尝试获取互斥锁失败时,线程被阻塞,而当一个线程尝试获取自旋锁失败时,线程忙等。也就相当于开一个while循环不断查询它是否被释放。自旋锁在等待时间短的时候可以节省线程重新调度的时间,但如果等得太久,就会因为没有让出cpu而浪费资源。编程时一般用不着自旋锁。自旋锁对应的类型是pthread_spinlock_t,同样具有init destroy lock unlock等一系列函数。

五、条件变量

在生产者/消费者问题问题中,基本逻辑是生产者发现缓冲区未满,进临界区获取临界资源,生产好了以后消费者进临界区拿临界资源。其中存在的问题是,假如消费者消耗得快,生产者生产得慢,则一段时间后所有消费者都要以while循环的方式询问生产者有没有准备好数据。这十分浪费资源,我们期望消费者在等生产者的时候进入阻塞,让出cpu。条件变量就是一个通信方式,它具有signal(),wait()两个基本函数,消费者需要拿数据的时候执行wait()阻塞线程,生产者准备好数据以后执行signal()通知wait()在这个条件变量上的线程并唤醒它们。

// 注意所有涉及cond的操作,都需要mutex进行保护
pthread_cond_t my_cond;
pthread_cond_init(&my_cond, NULL); // 使用默认参数初始化
pthread_cond_signal(&my_cond); // signal是唤醒等待在my_cond的一个线程
pthread_cond_broadcast(&my_cond); // broadcast是唤醒等待在my_cond的所有线程
pthread_cond_wait(&my_cond, &my_mutex); // wait是在my_cond处阻塞本线程
// pthread_cond_timedwait提供等待时间的选项,函数原型如下
extern int pthread_cond_timedwait (pthread_cond_t *__restrict __cond,
				   pthread_mutex_t *__restrict __mutex,
				   const struct timespec *__restrict __abstime)
// 第三个参数是timespec,代表从1970年1月1日0点至今的时间,我们需要根据当前时间和要等待的时长自己计算
// 使用timespec需#include <sys/time.h>
// 一个封装的例子如下
pthread_cond_t mID;
Mutex mQueryMutex; // 假设他俩都已经初始化
inline bool Event::Wait( const timespec& timeout )
{
	mQueryMutex.Lock();
	const timespec abs_time = timeAdd( timestamp(), timeout );
	const int ret = pthread_cond_timedwait(&mID, mQueryMutex.GetID(), &abs_time);
	if( ret == ETIMEDOUT )
	{
		mQueryMutex.Unlock();
		return false;
	}
	mQueryMutex.Unlock();
	return true;
}

inline bool Event::Wait( uint64_t timeout )		
{ 
	return (timeout == UINT64_MAX) ? Wait() : Wait(timeNew(timeout*1000*1000));
}

这里要注意,所有涉及cond的操作,都需要mutex进行保护,因为内部是通过判断及修改某个全局变量来决定线程的阻塞与唤醒,加锁可以保证线程安全。还有一个问题,cond_wait()之前已经进行了mutex_lock(),而运行cond_signal()之前也要mutex_lock()才行,这里好像是矛盾的。其实这是因为cond_wait()函数需要传入用于保护它的互斥锁,它内部已经对互斥锁进行解锁了,所以cond_signal()可以获得锁。

六、线程屏障

假设我们的程序要分两阶段进行,第一阶段结束后必须等待所有线程执行完,才能进入第二阶段。这时候我们就可以在所有线程中间(包括主线程)加入一个pthread_barrier,这时每个线程会确认所有其他线程都跑到barrier处,再一同往下进行。细节可以参考这里。在CUDA编程中,我们常调用的cudaDeviceSynchronize()内部就是用barrier实现的。

一点题外话,关于读者/写者和生产者/消费者的区别,我的理解是,读者/写者更底层,解决的是如何操作临界资源而不产生错误,它的逻辑是针对一份数据而言的。生产者/消费者高层一些,它的逻辑是针对许多份数据组成的缓冲区队列而言的,而生产和消费一定会包含读写操作。举例来说,一个线程不断从相机采集YUV图像放入缓冲队列,生产者相关的代码决定它何时可以把一帧图像放入队列,放入后要不要通知消费者。但具体到它把一帧数据放入缓冲区的时候,是写者相关的代码控制它不会和其他读者写者发生冲突。假设有另一个线程负责把刚才那个采集线程放入的图像进行颜色转换以便其他算法使用(例如YUV转RGB),那它既是采集线程的消费者,又是后续处理线程的生产者,它在进行YUV转RGB操作的时候,既是读者,又是写者。

参考资料: linux的「pthread.h」 pthread_exit()函数:终止线程 linux对线程等待和唤醒操作(pthread_cond_timedwait 详解)linux线程互斥量pthread_mutex_t使用简介 CS 241: System Programming Synchronization 使用条件变量的坑你知道吗?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值