1、Linux线程概述

Linux上两个最有名的线程库是LinuxThreads和NPTL(Native Posix Thread Library),它们都是采用1:1的方式实现的。所谓1:1即一个用户线程对应一个内核线程,内核线程获得cpu使用权就加载和运行用户线程,内核线程相当于用户线程的一个容器。

LinuxThreads线程库的内核线程是通过clone系统调用(与fork类似,创建调用进程的子进程)创建的进程模拟的,因此它拥有进程的一些特点。LInuxThreads线程库一个有名的特性是所谓的管理线程,它是专门用于管理其他工作线程的线程。管理线程的引入增加了额外的系统开销,且它只能运行在一个cpu上,因此LinuxThreads线程库也不能充分利用多处理器系统的优势。

NPTL提供的真正的内核线程,与LinuxThreads相比,它的主要优势在于:

  • 内核线程不再是一个进程,因此避免了很多用进程模拟内核线程导致的语义问题
  • 摒弃了管理线程,终止线程、回收线程堆栈等工作都可以由内核完成
  • 一个进程的线程可以运行在不同的cpu上,充分利用多处理器系统的优势
  • 线程的同步由内核完成,隶属于不同进程的线程间也能共享互斥锁,因此可实现跨进程的线程同步

编译使用了线程库的代码时需要加上 -lpthread选项。



2、创建线程和结束线程

#include <bits/pthreadtypes.h>
typedef unsigned long int pthread_t;

#include <pthread.h>
//创建线程
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void*(*start_routine)(void*), void* arg);
//结束线程 由线程自身调用
void pthread_exit(void* retval);
//回收线程 由其他线程调用
int pthread_join(pthread_t thread, void** retval);
//取消线程 异常终止
int pthread_cancel(pthread_t thread);

pthread_create调用创建一个新线程,参数thread是新线程的标识符,attr参数用于设置新线程的属性,传NULL表示使用默认线程属性,start_routine和arg分别制定新线程将运行的函数及其参数。pthread_create成功返回0,失败返回错误码。一个用户可以打开的线程数量不能超过RLIMIT_NPROC软资源限制,系统上所有用户能创建的线程总数也不得超过/proc/sys/kernel/thread-max内核参数所定义的值。

pthread_exit用于安全、干净的结束线程,retval参数用于向线程的回收者传递其退出信息。它执行后回到调用者,且永远不会失败。

一个进程中的所有线程都可以通过调用pthread_join来回收其他线程(前提是目标线程可回收),即等待其他线程结束,这类似于回收进程的wait和waitpid。thread参数指定要回收的线程,retval接收目标线程返回的退出信息。该函数会一直阻塞直到被回收的线程结束。成功返回0,失败返回错误码。可能的错误码如下图:

在这里插入图片描述

pthread_cancel用于异常终止一个线程,成功返回0,失败返回错误码。但是接收到取消请求的目标线程可以决定是否允许被取消以及如果取消。

#include <pthread.h>
//设置取消状态 即是否允许被取消
int pthread_setcancelstate(int state, int* oldstate);
//设置取消类型 即如何取消
int pthread_setcanceltype(int type, int* oldtype);

state可选值

  • PTHERAD_CANCEL_ENABLE:允许被取消,默认方式
  • PTHERAD_CANCEL_DISABLE:禁止被取消。这种情况下线程会将收到的取消请求挂起,直到允许取消

type可选值

  • PTHREAD_CANCEL_ASYNCHRONOUS:随时可取消
  • PTHREAD_CANCEL_DEFERRED:允许目标线程推迟行动,直到它调用了取消点函数,这是默认方式。取消点函数可以是下面的函数:pthread_join、pthread_testcancel、pthread_cond_wait、pthread_cond_timewait、sem_wait、sigwait、read、wait。取消点什么意思呢?是说当线程的取消类型被设置为推迟取消的时候,如果收到了取消的请求,会先将请求挂起,直到它遇到了取消点函数,才会检查当前有没有被挂起的取消请求,有且state被设置为允许被取消,才会退出。因此,为了安全起见,最好在可能会被取消的代码中调用pthread_testcancel来设置取消点。

    pthread_setcancelstate和pthread_setcanceltype成功返回0,失败返回错误码。



3、线程属性

pthread_attr_t定义了一套完整的线程属性

#include <bits/pthreadtypes.h>
#define __SIZEOF_PTHREAD_ATTR_T 36
typedef union
{
	char __size[__SIZEOF_PTHREAD_ATTR_T];
	long int __align;
} pthread_attr_t

可见线程属性全部包含在一个字符数组中。线程库定义了一系列 int pthread_attr_get*/int pthread_attr_set*的函数来获取和设置线程属性。线程属性含义为:

  • detachstate 线程的脱离状态。有PTHREAD_CREATE_JOINABLE和PTHREAD_CREATE_DETACH两个可选值。前者是默认值,指定线程可被回收,后者使调用线程脱离与进程中其他线程的同步,称为“脱离线程”,脱离线程在退出时自行释放其占用的系统资源。此外也可使用pthread_detach函数将线程设为脱离线程。
  • stackaddr和stacksize 线程堆栈的起始地址和大小,一般我们不需要自己管理线程堆栈,Linux默认为每个线程分配足够的堆栈空间,一般是8MB。(

    这里可以看出线程虽然共享进程的资源,但是拥有独立的堆栈

  • guardsize 保护区域大小。保护区域是指防止栈溢出的区域,guardsize用于指定这块区域的大小,默认PAGESIZE(即页大小)。如果使用者调用了pthread_attr_setstackaddr或pthread_attr_setstacksize,则系统认为用户要自己控制栈区,guardsize属性将失效。
  • schedparam 线程调度参数,其类型是sched_param,目前只有一个整型成员sched_priority,表述线程运行的优先级。
  • schedpolicy 线程调度策略,可选值有SCHED_FIFO(采用先进先出方法调度)、SCHED_RR(采用轮转算法调度)、SCHED_OTHER(默认)
  • inheritsched 是否继承调用线程的调度属性,可选值有PTHREAD_INHERIT_SCHED和PTHREAD_EXPLICIT_SCHED。前者表示新线程沿用其创建者的线程调度参数,这种情况下再设置新线程的调度参数属性将无效。后者表示调用者要明确指定新线程的调度参数。
  • scope 线程间竞争cpu的范围,即线程优先级的有效范围。可选值PTHREAD_SCOPE_SYSTEM和PTHREAD_SCOPE_PROCESS,前者表示目标线程与系统中所有线程一起竞争cpu的使用,后者表示仅与其他隶属于同一进程的线程竞争cpu的使用。目前linux只支持PTHREAD_SCOPE_SYSTEM。



4、线程同步

类似于多进程程序,多线程程序也需要考虑线程间的同步问题。POSIX信号量、互斥锁与条件变量都是用于线程间同步的机制。



(1)POSIX信号量

POSIX信号量与System V IPC信号量接口相似,语义完全相同。

#include <semaphore.h>
/* 初始化一个未命名的信号量
   pshared指定信号量类型,为0表示信号量为当前进程的局部信号量,否则该信号量可在多个进程间共享
   value指定信号量的初值
   初始化一个已经初始化过的信号量将导致不可预期的结果*/
int sem_init(sem_t* sem, int pshared, unsigned int value);

/* 销毁信号量,释放其所占内核资源
   销毁一个正被其他线程等待的信号量将导致不可预期的结果*/
int sem_destroy(sem_t* sem);

/* 以原子操作方式将信号量减1
   若信号量值为0,则阻塞,直到信号量具有非0值*/
int sem_wait(sem_t* sem);

/* sem_wait的非阻塞版本
   当信号量的值为0时返回-1,设置errno为EAGAIN,否则减1*/
int sem_trywait(sem_t* sem);

/* 以原子操作方式将信号量加1
   当信号量的值大于0,其他正在调用sem_wait等待信号量的线程将被唤醒*/
int sem_post(sem_t* sem);

以上函数成功返回0,失败返回-1,设置errno。



(2)互斥锁

互斥锁用于保护关键代码段(临界区),以确保其被某个线程独占式的访问。要进入临界区的线程必须先获得互斥锁(加锁),离开时需要释放互斥锁(解锁),以唤醒其他等待互斥锁的线程。



基础API
#include <pthread.h>
/* 初始化互斥锁mutex
   mutexattr指定互斥锁属性,设为NULL,表示使用默认属性
   也可以用pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER方式初始化*/
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* mutexattr);

/* 销毁互斥锁以释放其占用的内核资源
   销毁一个已经加锁的互斥锁将导致不可预期的结果*/
int pthread_mutex_destroy(pthread_mutex_t* mutex);

/* 以原子操作对互斥锁加锁
   如果mutex已被锁上,则阻塞,直到mutex被解锁*/
int pthread_mutex_lock(pthread_mutex_t* mutex);

/* pthread_mutex_lock的非阻塞版本
   若mutex已被加锁,则返回错误码EBUSY
   要注意这里讨论的pthread_mutex_lock和pthread_mutex_trylock的行为都是针对普通锁,对于其他锁
   这两个加锁函数会有不同行为*/
int pthread_mutex_trylock(pthread_mutex_t* mutex);

/* 以原子操作对一个互斥锁解锁*/
int pthread_mutex_unlock(pthread_mutex_t* mutex);

上面这些函数成功返回0,失败返回错误码。



互斥锁属性

互斥锁属性由pthread_mutexattr_t定义,互斥锁属性是个对象,使用前需要初始化,使用后需要销毁。

#include <pthread.h>
// 初始化互斥锁属性对象
int pthread_mutexattr_init(pthread_mutexattr_t* attr);
// 销毁互斥锁属性对象
int pthread_mutexattr_destroy(pthread_mutexattr_t* attr);

互斥锁属性的获取和设置由pthread_mutexattr_get

和pthread_mutexattr_set

完成。

互斥锁的两个常用属性是pshared和type。

pshared指定是否允许跨进程共享互斥锁,可选值两个

  • PTHREAD_PROCESS_SHARED 可被跨进程共享
  • PTHREAD_PROCESS_PRIVETE 只能被和锁的初始化线程隶属于同一个进程的线程共享

type指定互斥锁类型

  • PTHREAD_MUTEX_NORMAL,普通锁。互斥锁默认类型。请求对普通锁加锁的线程将形成一个等待队列,在该锁解锁后,这些线程按优先级获得它。这种所容易引发的问题是:1,对一个已经加锁的普通锁再次加锁将引发死锁。2,对一个已被其他线程加锁的普通锁解锁或者对一个已经解锁的普通锁再次解锁都将导致不可预期的后果。
  • PTHREAD_MUTEX_ERRORCHECK,检错锁。对比普通锁引发的两个问题,检错锁则返回EDEADLK和EPERM。
  • PTHREAD_MUTEX_RECURSIVE,嵌套锁。这种锁允许一个线程在释放锁之前多次对它加锁而不发生死锁。不过其他线程如果要获得这个锁,则当前锁的拥有者必须执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套锁解锁,或对一个已经解锁的嵌套锁再次解锁,则返回EPERM。
  • PTHREAD_MUTEX_DEFAULT,默认锁。一个线程如果对一个已经被加锁的默认锁再次加锁,或者以一个已经被其他线程加锁的默认锁解锁,或者对一个已经解锁的默认锁再次解锁,将导致不可预期的后果。这种锁在实现的时候被映射为上面三种锁之一。


死锁

死锁使得一个或多个线程被挂起而无法继续执行。举个例子:

pthread_mutex_t mutex_a;
pthread_mutex_t mutex_b;
void* threadA(void* arg)
{
	pthread_mutex_lock(&mutex_a);
	sleep(5);
	//将被永远阻塞在这里
	pthread_mutex_lock(&mutex_b);
	pthread_mutex_unlock(&mutex_a);
	pthread_mutex_unlock(&mutex_b);
	...
}
void* threadB(void* arg)
{
	pthread_mutex_lock(&mutex_b);
	sleep(5);
	//将被永远阻塞在这里
	pthread_mutex_lock(&mutex_a);
	pthread_mutex_unlock(&mutex_b);
	pthread_mutex_unlock(&mutex_a);
	...
}

线程A与线程B同时执行,A等待B释放mutex_b,B等待A释放mutex_a,两者互相等待,形成死锁。若没有sleep,上述伪代码是正常运行还是形成死锁,嗯,看运气吧。



(3)条件变量


条件变量传送门



5、多线程环境



(1)线程安全函数

如果一个函数能被多个线程同时调用且不发生竞态条件,则称它为线程安全函数,或者说它是可重入函数。linux库函数只有一小部分是不可重入的。不可重入主要是因为其内部使用了静态变量。不过linux对很多不可重入的库函数提供了可重入版本,函数名为原函数名尾加_r。



(2)线程和进程

一个多线程程序里的某个线程调用了fork,那么新创建的子进程不会创建和父进程相同数量的线程,它只有一个执行线程,它是调用fork的那个线程的完整复制。且子进程将自动继承父进程中的互斥锁(条件变量类似)的状态,即父进程中被锁的互斥锁在子进程中也是被锁的,这就引起了一个问题,子进程不知道自己继承的这个锁的状态,不清楚状态就无法正确操作。

pthread提供了一个专门的函数来确保fork调用后父子进程都拥有一个清楚的锁状态

#include <pthread.h>
int pthread_atfork(void(*prepare)(void), void(*parent)(void), void(*child)(void));

//pthread_atfork使用实例
void prepare()
{
	pthread_mutex_lock(&mutex);
}
void infork()
{
	pthread_mutex_unlock(&mutex);
}
pthread_atfork(prepare, infork, infork);

pthread_atfork的工作流程如下

  1. 调用prepare锁住互斥锁,如果此时互斥锁已被锁,则pthread_atfork将随着pthread_mutex_lock被阻塞
  2. 调用fork创建子进程
  3. 在父进程中执行parent函数(即上面的infork)对互斥锁解锁,在子进程中执行child函数对互斥锁解锁
  4. pthread_atfork返回

这样就保证了子进程继承的锁肯定是未锁状态。pthread_atfork成功返回0,失败返回错误码。



(3)线程和信号

每个线程可独立设置信号掩码。设置进程信号掩码的函数是sigprocmask,在多线程环境下使用pthread版本的sigprocmask函数,如下

#include <pthread.h>
#include <signal.h>
int pthread_sigmask(int how, const sigset_t* newmask, sigset_t* oldmask);

该函数参数含义与sigprocmask的参数完全相同。成功返回0,失败返回错误码。

同一个进程下的所有线程共享该进程的信号,因此线程库要根据线程掩码决定把信号发给哪个线程。但是当每个子线程都单独设置了信号掩码后,要处理信号转发的线程并不清楚其他线程的线程掩码,很容易导致逻辑错误。此外,所有线程共享信号处理函数。这两点都说明应该定义一个专门的线程来处理所有信号,可以通过以下两步实现

  1. 在主线程创建出其他子线程前就设置好信号掩码,之后创建的子线程将继承这个信号掩码,且不能更改
  2. 在某个线程中调用sigwait函数来等待信号,并做处理,则这个线程即为专门处理信号的线程
#include <signal.h>
/* set为信号集 可简单指定为上面步骤1中的信号掩码
   接收到的信号值将被存于sig指定的整数中 
   函数成功时返回0,失败返回错误码 */
int sigwait(const sigset_t* set, int* sig);

显然,sigwait会与信号处理函数冲突,信号发生时,二者只能响应其一。

下面是代码清单14-5源码,取自pthread_sigmask函数的man手册,它展示了如何通过上面两步实现在一个线程中统一处理所有信号。

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

#define handle_error_en(en, msg) \
	do {errno = en; perror(msg); exit(EXIT_FAILURE);} while(0)

static void *sig_thread(void* arg)
{
	sigset_t* set = (sigset_t*)arg;
	int s, sig;
	for (;;)
	{
		// 调用sigwait等待信号
		s = sigwait(set, &sig);
		if (s != 0)
		{
			handle_error_en(s, "sigwait");
		}
		printf("signal handling thread got signal %d\n", sig);
	}
}

int main(int argc, char* argv[])
{
	pthread_t thread;
	sigset_t set;
	int s;

	//主线程中设置信号掩码
	sigemptyset(&set);
	sigaddset(&set, SIGQUIT);
	sigaddset(&set, SIGUSR1);
	s = pthread_sigmask(SIG_BLOCK, &set, NULL);
	if (s != 0)
	{
		handle_error_en(s, "pthread_sigmask");
	}
	//创建处理信号的线程
	s = pthread_create(&thread, NULL, &sig_thread, (void*)&set);
	if (s != 0)
	{
		handle_error_en(0, "pthread_create\n");
	}

	pause();
}

最后,pthread提供了下面的方法将一个信号明确的发送给指定线程

#include <signal.h>
int pthread_kill(pthread_t thread, int sig);

sig传0时,函数不发送信号,但仍然会进行错误检查,因此可以利用这种方法检测目标线程是否存在。函数成功返回0,失败返回错误码。



版权声明:本文为qq_20363225原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/qq_20363225/article/details/122265520