多线程编程总结

这一生的挚爱 提交于 2020-02-24 05:48:57
一、线程模型:
线程是程序中完成一个独立任务的完整执行序列,即一个可调度的实体。根据运行环境和调度者的身份,线程可分为内核线程和用户线程。
内核线程:运行在内核空间,由内核来调度;
用户线程:运行在用户空间,由线程库来调用。
 
当进程的一个内核线程获得CPU的使用权时,它就加载并运行一个用户线程。可见,内核程序相当于用户线程运行的容器。一个进程可以拥有M个内核线程和N个用户线程,其中M≤N。并且在一个系统的所有进程中,M和N的比值都是固定的。按照M:N的取值,线程的实现方式可分为三种模式:完全在用户空间实现、完全由内核调度和双层调度。
 
1、完全在用户空间实现的线程无须内核的支持,内核甚至根本不知道这些现成的存在。线程库负责管理所有执行线程,比如线程的优先级、时间片等。线程库利用longjmp来切换线程的执行,使它们看起来像是“并发”执行的。但实际上内核仍然是把整个进程作为最小单位来调度的。换句话说,一个进程的所有执行线程共享该进程的时间片,它们对外表现出相同的优先级。因此,对于这种实现方式而言,M=1,即N个用户空间线程对应1个内核线程,而该内核线程实际上就是进程本身。
完全在用户空间实现的线程的优点是:创建和调度线程都无需内核的干预,因此速度相当快。并且由于它不占用额外的内核资源,所以即使一个进程创建了很多线程,也不会对系统性能造成明显的影响。其缺点是:对于多处理器系统,一个进程的多线程无法运行在不同的CPU上,因为内核是按照其最小调度单位来分配CPU的。此外,线程的优先级只对同一个进程中的线程有效,比较不同进程中的线程的优先级没有意义。
 
2、完全由内核调度的模式将创建、调度线程的任务都交给了内核,运行在用户空间的线程库无须执行管理任务,这与完全在用户空间实现的线程恰恰相反。二者的优缺点也正好互换。完全由内核调度的这种线程实现方式满足M:N=1:1,即1个用户空间线程被映射为1个内核线程。
 
3、双层调度模式是前两种实现模式的混合体:内核调度M个内核线程,线程库调用N个用户线程。这种线程实现方式结合了前两种方式的优点:不但不会消耗过多的内核资源,而且线程切换速度也较快,同时它可以充分利用多处理器的优势。
 
二、Linux线程库
Linux上最有名的线程库是LinuxTreads和NPTL,它们都是采用1:1的方式实现的。
用户可以使用如下命令来查看当前系统上所使用的线程库:
$ getconf GNU_LIBPTHREAD_VERSION
NPTL 2.14.90
 
三、创建线程和结束线程
1、pthread_create:创建一个线程
#include <pthread.h>
int pthread_create( pthread_t* threadconst pthread_att_t* attr,
   void* (*start_routine)(void*),void* arg);
thread参数是新线程的标识符,后续pthread_t*函数通过它来引用新的线程。其类型pthread_t的定义如下:
#include <bits/pthreadtypes.h>
typedef unsigned long int pthread_t;
attr参数用于设置新线程的属性。给它传递NULL表示使用默认的线程属性。start_routine和arg参数分别指定新线程将运行的函数及其参数。
pthread_create成功时返回0,失败时返回错误码。一个用户可以打开的线程数量不能超过RLIMIT)NPROC软资源限制。此外,系统上所有用户能创建的线程总数也不能超过/pro/sys/kernel.threads-max内核参数所定义的值。
 
2、pthread_exit
线程一旦被创建好,内核就可以调度内核线程来执行start_routine函数指针所指向的函数了。线程函数在结束时最好调用如下函数,以确保安全、干净的退出。
#include <pthread.h>
void pthread_exit(void* retval);
pthread_exit函数通过retval参数向线程的回收者传递其退出信息。它执行完之后不会返回到调用者,而且永远不会失败。
 
3、pthread_join
一个进程中的所有线程都可以调用pthread_join函数来回收其他线程,即等待其他线程结束,这类似于回收进程的wait和waitpid系统调用。
#include <pthread.h>
int pthread_join(pthread_t thread,void** retval);
thread参数是目标线程的标识符,retval参数则是目标线程返回的退出消息。该函数会一直阻塞,直到被回收的线程结束为止。该函数成功时返回0,失败则返回错误码。
错误码 描述
EDEADLK 可能引起死锁。比如两个线程互相针对对方调用pthread_join,或者线程对自身调用pthread_join。
EINVAL 目标线程是不可回收的,或者已有其他线程在回收该目标线程
ESRCH 目标线程不存在
 
4、pthread_cancel
有时候我们希望异常终止一个线程,即取消线程。
#include <pthread.h>
int pthread_cancel(pthread_t thread);
threat参数是目标线程的标识符。该函数返回成功时返回0,失败返回错误码。不过,接收到取消请求的目标线程可以决定是否允许被取消以及如何取消。
#include <pthread.h>
int pthread_setcancelstate(int state,int* oldstate);
int pthread_setcanceltype(int type,int* oldtype);
这两个函数的第一个参数分别用于设置线程的取消状态(是否取消)和取消类型(如何取消),第二个参数分别记录线程原来的取消状态和取消类型。
 
四、POSIX信号量
专门用于线程同步的机制:POSIX信号量、互斥量和条件变量。
常用的POSIX信号量函数是下面5个:
#include <semaphore.h>
int sem_init(sem_t* sem,int pshared,unsigned int value);
int sem_destroy(sem_t* sem);
int sem_wait(sem_t* sem);
int sem_trywait(sem_t* sem);
int sem_post(sem_t* sem);
这些函数的第一个参数sem指向被操作的信号量。
sem_init函数用于初始化一个未命名的信号量。pshared参数指定信号量的类型。如果其值为0,就表示这个信号量是当前进程的局部信号量,否则该信号量就可以在多个进程之间共享。value参数指定信号量的初始值。此外,初始化一个已经被初始化的信号量将导致不可预期的结果。
sem_destroy函数用于销毁信号量,以释放其占用的内核资源。如果销毁一个正被其他线程等待的信号量,则将导致不可预期的结果。
sem_wait函数以原子操作的方式将信号量减1。如果信号量的值为0,则sem_wait将被阻塞,直到这个信号量具有非0值。
sem_trywait与sem_wait函数相似,不过它始终立即返回,而不论被操作的信号量是否具有非0值,相当于sem_wait的非阻塞版本。当信号量的值非0时,sem_trywait对信号量执行减1操作。当信号量的值为0时,它将返回-1并设置errno为EAGAIN。
sem_post函数以原子操作的方式将信号量的值加1。当信号量的值大于0时,其他正在调用sem_wait等待信号的线程将被唤醒。
上面这些函数,成功时返回0,失败则返回-1并设置errno。
 
五、互斥锁
POSIX互斥锁的相关函数主要有5个:
#include <pthread.h>
int pthread_mutex_init( pthread_mutex_t *mutex, 
   const pthread_mutexattr_t *mutexattr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
这些函数的第一个参数mutex指向要操作的目标互斥锁,互斥锁的类型是pthread_mutex_t结构体。
pthread_mutex_init函数用于初始化互斥锁。mutexattr参数指定互斥锁的属性。如果将它设置为NULL,则表示使用默认属性。
pthread_mutex_destroy函数用于销毁互斥锁,以释放其占用的内核资源。销毁一个已经加锁的互斥锁将导致不可预期的后果。
pthread_mutex_lock函数以原子操作的方式给一个互斥锁加锁。如果目标互斥锁已经被锁上,则pthread_mutex_lock调用将阻塞,直到该互斥锁的占有者将其解锁。
pthread_mutex_trylock与pthread_mutex_lock函数类似,不过它始终立即返回,而不论被操作的互斥锁是否已经被加锁,相当于pthread_mutex_lock的非阻塞版本。当目标互斥锁未被加锁时,pthread_mutex_trylock将返回错误码EBUSY。
pthread_mutex_unlock函数以原子操作的方式给一个互斥锁解锁。如果此时有其他线程正在等待这个互斥锁,则这些线程中的某一个将获得它。
上面这些函数,成功时返回0,失败则返回错误码。
 
互斥锁属性type指定互斥锁的类型。Linux支持如下4种类型的互斥锁:
1、PTHREAD_MUTEX_NORMAL,普通锁。这是互斥锁默认的类型。当一个线程对一个普通锁加锁以后,其余请求该锁的线程将形成一个等待队列,并在该锁解锁后按优先级获得它。这种锁类型保证了资源分配的公平性。但这种锁也很容易引发的问题:一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁;对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁再次解锁,将导致不可预期的后果。
2、PTHREAD_MUTEX_ERRORCHECK,检查锁。一个线程如果对一个已经加锁的检查锁再次加锁,则加锁操作返回EDEADLK;对一个已经被其他线程加锁的检查锁解锁,或者对一个已经解锁的检查锁再次解锁,则解锁操作返回EPERM。
3、PTHREAD_MUTEX_RECURSIVE,嵌套锁。这种锁允许一个线程在释放锁之前多次对它加锁而不发生死锁。不过其他线程如果要获得这个锁,则当前锁的拥有者必须执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套锁解锁,或者对一个已经解锁的嵌套锁再次解锁,则解锁操作返回EPERM。
4、PTHREAD_MUTEX_DEFAULT,默认锁。一个线程如果对一个已经加锁的默认所再次加锁,或者对一个已经被其他线程加锁的默认锁解锁,或者对一个已经解锁的默认所再次解锁,将导致不可预期的后果。这种锁在实现的时候可能被映射为上面三种锁之一。
 
六、可重入函数
如果一个函数能被多个线程同时调用且不发生竞态条件,则我们称它是线程安全的,或者说它是可重入函数。Linux库函数只有一小部分是不可重入的,这些库函数之所以不可重入,只要是其内部使用了静态变量。
 
七、线程和进程
如果一个多线程程序的某个线程调用了fork函数,那么新创建的子进程是否将自动创建和父进程相同数量的线程呢?
答:不是的。子进程只拥有一个执行程序,它是调用fork的那个线程的完整复制。并且子进程将自动继承父进程中的互斥锁的状态。也就是说,父进程中已经被加锁的互斥锁在子进程中也是被锁住的。这就引发了一个问题:子进程可能不清楚从父进程继承而来的互斥锁的具体状态(是加锁还是不加锁)。这个互斥锁可能被加锁了,但并不是由调用fork函数的那个线程锁住的,而是由其他线程锁住的。如果是这样的情况,而子进程若再次对该互斥锁执行加锁操作会导致死锁。
不过,pthread提供了一个专门的函数pthread_atfork,以确保fork调用后父进程和子进程都拥有一个清楚的锁的状态。该函数定义如下:
#include <pthread.h>
pthread_atfork(void (*prepare)(void),void (*parent)(void),void (*child)(void));
prepare句柄将在fork调用创建出子进程之前被执行。它可以用来锁住所有父进程中的互斥锁。parent句柄则是fork调用创建出子进程之后,而fork返回之前,在父进程中被执行。它的作用是释放所有在prepare句柄中被锁住的互斥锁。child句柄是fork返回之前,在子进程中被执行。和parent句柄一样,child句柄也是用于释放所有在prepare句柄中被锁住的互斥锁。
该函数成功时返回0,失败返回错误码。
 
八、线程和信号
每个线程都可以独立的设置信号掩码。
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!