Linux平台服务器多线程开发(一)
线程模型
? ? ? ? 线程是程序中完成一个独立任务的完整执行序列,即一个可调度的实体。根据运行环境和调度者的身份,线程可分为内核线程和用户线程。内核线程,在有的系统上也称为LWP(Light Weigth Process,轻量级进程),运行在内核空间,由内核来调度;用户线程运行在用户空间,由线程库来调度。当进程的一个内核线程获得CPU的使用权时,它就加载并运行一个用户线程。可见,内核线程相当于用于线程运行的容器。一个进程可以拥有M个内核线程和N个用户线程,其中M≤N。并且在一个系统的所有进程中,M和N的比值都是固定的。按照M:N的取值,线程的实现方式可分为三种模式:完全在用户空间实现、完全由内和调度和双层调度。
? ? ? ? 完全在用户空间实现的线程无需内核的支持,内核甚至根本不知道这些线程的存在。线程库负责管理所有执行线程,比如线程的优先级、时间片等。线程库利用longjmp来切换线程的执行,使它们看起来像是并发执行的。但实际上内核仍然是把整个进程作为最小单位来调度的。换句话说,一个进程的所有执行线程共享该进程的时间片,它们对外表现出相同的优先级。因此,对这种实现方式而言,N=1,即M个用户空间线程对于1个内核线程,而该内核线程实际上就是进程本身。完全在用户空间实现的线程的优点是:创建和调度线程都无须内核的干预,因此速度相当快。并且由于它不占用额外的内核资源,所有即使一个进程创建了很多线程,也不会对系统性能造成明显的影响。其缺点是,对于多处理器系统,一个进程的多个线程无法运行在不同的CPU上,因为内核是按照其最小调度单位来分配CPU的。此外,线程的优先级只对同一个进程中的线程有效,比较不同进程中的线程的优先级没有意义。
完全由内核调度的模式将创建、调度线程的任务都交给了内核,运行在用户空间的线程无需执行管理任务,这与完全在用户空间实现的线程恰恰相反。完全由内核调度的这种线程实现方式满足M:N=1:1,即1个用户空间线程被映射为1个内核线程。
双层调度模式是前两种实现模式的混合体:内核调度M个内核线程,线程库调度N个用户线程。这种线程实现方式结合了前两种方式的优点:不但不会消耗过多的内核资源,而且线程切换速度也较快,同时她可以充分利用多处理器的优势。
创建线程和结束线程
pthread_create
#include <pthread.h>
int pthread_create(pthread_t thread, const pthread_attr_t attr, void (start_routine) (void ), void arg);
thread参数是新县城的标识符,后续pthread_函数通过它来应用新线程。其类型pthread_t定义如下:
#include <bits/pthreadtypes.h>
typedef unsigned long int pthread_t
arg参数用于设置新线程的属性。给它传递NULL表示使用默认线程属性。线程拥有众多属性,我们将在后面讨论。start_routine和arg参数分别指定新线程将运行的函数及其参数。pthread_create成功时返回0,失败是返回错误码。
pthread_exit
? ? ? ? 线程一旦被创建好,内核就可以调度内核线程来执行start_routine函数指针所指向的 函数了。线程函数在结束时最好调用如下函数,以确保安全、干净退出。
#include <pthread.h>
void pthread_exit(void retval);
pthread_exit函数通过retval参数向线程的回收者传递其退出信息。它执行完之后不会返回到调用者,而且永远不会失败。
pthread_join
一个进程中的所有线程都可以调用pthread_join函数来回收其他线程,即等待其他线程结束,这类似于回收进程的wait和waitpid系统调用。pthread_join的定义如下:
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
thread参数是目标线程的标识符,retval参数则是目标线程返回的退出信息。该函数会一直阻塞,知道被回收的线程结束为止。该函数成功时返回0,失败时返回错误码。可能的错误码如下表:
错误码 描述
EDEADLK 可能引起死锁。比如两个线程互相对对方调用pthread_join,或者线程对自身调用pthread_join
EINVAL 目标线程是不可回收的,或者已经有其他线程在回收该目标线程
ESRCH 目标线程不存在
pthread_cancle
有时候我们希望终止一个线程,即取消线程,它是通过如下函数实现的:
#include <pthread.h>
int pthread_cancel(pthread_t thread);
thread参数是目标线程的标识符。该函数成功时返回0,失败时返回错误码。不过,接收到取消请求的目标线程可以决定是否允许被取消以及如何取消,这分别由如下两个函数完成。
#include <pthread.h>
int pthread_setcancelstate(int state, int oldstate);
int pthread_setcanceltype(int type, int oldtype);
这两个函数的第一个参数分别用于设置线程的取消状态(是否允许取消),和取消类型(如何取消)。第二个参数则分别 线程原来的取消状态和取消类型。state参数有两个可选值:
PTHREAD_CANCEL_ENABLE,允许线程被取消。它是线程被创建的默认取消状态。
PTHREAD_CANCEL_DISABLE,禁止线程被取消。这种情况下,如果一个线程收到取消请求,则它会将请求挂起,直到该线程允许被取消。
type参数也有两个可选值:
PTHREAD_CANCEL_ASYNCHRONOUS,线程随时可以被取消。它将使得接收到取消请求的目标线程立即采取行动。
PTHREAD_CANCEL_DEFERROR,允许目标线程推迟行动,直到它调用了所谓的取消点函数。
这两个函数成功时返回0,失败时返回错误码。
线程属性
pthread_attr_t结构体定义了一套完整的线程属性,如下所示:
#inlcude <bits/pthreadtypes.h>
#define _SIZEOF_PTHREAD_ATTR_T 36
typedef union
{
char size[SIZEOF_PTHREAD_ATTR_T];
long int __align;
} pthread_attr_t;
各种线程属性武安不包含在一个字符数组中。线程库定义了一些列函数来操作pthread_attr_t类型的变量,以方便我们获取和设置线程属性。我们可以用pthread_attr_t结构修改线程默认属性,并把这些属性与创建的线程联系起来。可以使用pthread_attr_init函数初始化pthreaad_attr_t结构(或者叫初始化线程属性对象)。调用pthread_attr_init以后,pthread_attr_t结构所包含的内容就是操作系统实现支持的线程所有属性的默认值。如果要修改其中个别属性的值,需要调用其他的函数。pthread_attr_destroy可以去除对pthread_attr_t结构的初始化(销毁线程属性对象)。
#include<pthread.h>
intpthread_attr_init(pthread_attr_t attr);
intpthread_attr_destroy(pthread_attr_t attr);
POSIX.1定义的线程属性主要有detachstate(线程的分离状态属性),guardsize(线程栈末尾的警戒缓冲区大小),stackaddr(线程栈最低地址),stacksize(线程栈的大小(字节数))。
如果对现有的某个线程的终止状态不感兴趣,可以使用pthread_detach函数让操作系统在线程退出时回收所占用的资源。如果在创建线程时就知道不需要了解线程的终止状态,则可以修改pthread_attr_t结果中的detachstate线程属性,让线程以分离状态启动。可以使用pthread_attr_setdetachstate把线程属性detachstate设置为下面的合法值之一:设置PTHREAD_CREATE_DETACHED,以分离状态启动线程;或者以PTHREAD_CREATE_JOINABLE,正常启动线程,引用程序可以获取线程的终止状态。
可以调用pthread_attr_getdetachstate函数获取当前detachstate线程属性,第二个参数所指向的整数也许被设置为PTHREAD_CREATE_DETACHED,也可能被设置为PTHREAD_CREATE_JOINABLE。
#include<pthread.h>
intpthread_attr_setdetachstate(pthread_attr_t attr, int detachstate);
int pthread_attr_getdetachstate(pthread_attr_tattr, int detachstate)
函数pthread_attr_getstack和pthread_attr_setstack可以对线程栈属性进行查询和修改。
#include<pthread.h>
int pthread_attr_setstack(pthread_attr_tattr, void stackaddr, size_t stacksize);
int pthread_attr_getstack(pthread_attr_tattr, void *stackaddr, size_t stacksize);
这两个函数可以用于管理stackaddr线程属性和stacksize线程属性。应用程序也可以通过pthread_attr_setstacksize和pthread_attr_getstacksize函数读取或设置线程属性stacksize。
#include<pthread.h>
intpthread_attr_setstacksize(pthread_attr_t attr, size_t stacksize);
intpthread_attr_getstacksize(pthread_attr_t attr, size_t stacksize);
线程属性guardsize控制着线程栈末尾之后用以避免栈溢出的扩展内存大小。这个属性默认设置为PAGESIZE个字节。可以把guardsize线程属性设置为0,从而不允许属性的这种特征行为发生:在这种情况下,不会提供警戒缓冲区。同样的,如果对线程属性stackaddr做了修改,系统就会假设我们会自己管理栈,并使警戒缓冲区机制无效,等同于guardsize线程属性设为0。
#include<pthread.h>
intpthread_attr_setguardsize(pthread_attr_t attr, size_t guardsize);
intpthread_attr_getguardsize(pthread_attr_t attr, size_t guardsize);
如果guardsize线程属性被修改了,操作系统可能把它取为页大小的整数倍。如果线程的栈指针溢出到警戒区,应用程序就可以通过信号接收到出错信息。
POSIX信号量
线程同步的机制下面讲3种:信号量、互斥量和条件变量。
#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_int用于初始化一个未命名的信号量。pshared参数指定信号量的类型。如果pshared参考指定信号量的类型。如果其值为0,就表示这个信号量是当前进程的局部信号量,否则该信号量就可以在多个进程之间共享。value参数指定信号量的初始值。此外,初始化一个已经被初始化的信号量将导致不可预期的结果。
sem_destroy函数用于销毁信号量,以释放期占用的内核资源。如果销毁一个正在被其他线程等待的信号量,则将导致不可预期的后果。
sem_wait函数以原子操作的方式将信号量减1。如果信号量的值为0,则sem_wait将被阻塞,直到这个信号量具有非0值。
sem_trywait与sem_wait函数相似,不过它始终立即返回,而不论被操作的信号是否具有非0值,相当于sem_wait的非阻塞版本。当信号量的值非0时,sem_trywait对信号量执行减1操作。当信号量的值非0时,sem_trywait对信号量执行减1操作。当信号量的值为0时,它将返回-1并设置errno为EAGAIN。
sem_post函数以原子操作的方式将信号量的值加1.当信号量的值大于0时,其他正在调用sem_wait等待信号量的线程将被唤醒。
上面这些函数成功时返回0,失败是返回-1并设置errno。
互斥锁
互斥锁基础API
POSIX互斥锁的相关函数主要有如下5个:
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t mutex);
int pthread_mutex_init(pthread_mutex_t restrict mutex,
const pthread_mutexattr_t restrict attr);
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_t mutex = PTHREAD_MUTEX_INITIALIZER;
宏PTHREAD_MUTEX_INITIALIZER实际上只是把互斥锁的各个字段都初始化为0。
pthread_mutex_destroy函数用于小胡互斥锁,以释放期占用的内核资源。销毁一个已经加锁的互斥锁将导致不可预期的后果。
pthread_mutex_lock函数以原子操作的方式给一个互斥锁加锁。如果目标互斥锁已经被锁上,则pthread_mutex_lock调用将阻塞,直到该互斥锁的占有者将其解锁。
pthread_mutex_trylock与pthread_mutex_lock函数类似,不过它始终立即返回,而不论被操作的互斥锁是否已经加锁,相当于pthread_mutex_lock的非阻塞版本。当目标互斥锁未被加锁时,pthread_mutex_trylock对互斥锁执行加锁操作。当互斥锁已经被加锁时,pthread_mutex_trylock将返回错误码EBUSY。需要注意的是,这里讨论的pthread_mutex_lock和pthread_mutex_trylock的行为是针对普通锁而言的。
pthread_mutex_unlock函数以院子操作的方式给一个互斥锁解锁。如果此时有其他线程正在等待这个互斥锁,则这些线程中的某一个将获得它。
上面这些函数成功时返回0,失败时返回错误码。
互斥锁属性
pthread_mutexattr_t结构体定义了一套完整的互斥锁属性。线程库提供了一系列函数来操作pthread_mutexattr_t类型变量,以方便我们获取和设置互斥锁属性。这里我们列出其中一些主要的函数:
#include <pthread.h>
int pthread_mutexattr_destroy(pthread_mutexattr_t attr);
int pthread_mutexattr_init(pthread_mutexattr_t attr);
int pthread_mutexattr_getpshared(const pthread_mutexattr_t
restrict attr, int restrict pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t attr, int pshared);
int pthread_mutexattr_gettype(const pthread_mutexattr_t restrict attr, int restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t attr, int type);
这里只讨论互斥锁的两种常用属性:pshared和type。互斥锁属性pshared指定是否允许跨进程共享互斥锁,其可选值有两个:
PTHREAD_PROCESS_SHARED。互斥锁可以被跨进程共享。
PTHREAD_PROCESS_PRIVATE。互斥锁只能被和锁的初始化线程隶属于同一个进程的线程共享。
互斥锁属性type指定互斥锁的类型。Linux支持如下4种类型的互斥锁:
PTHREAD_MUTEX_NORMAL,普通锁。这是互斥锁默认的类型。当一个线程对一个普通锁加锁以后,其余请求该所的线程将形成一个等待队列,并在该所解锁后按优先级获得它。这种锁类型保证了资源分配的公平性。但这种锁也很容易引发问题:一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁;对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁解锁将导致不可预期的后果。
PTHREAD_MUTEX_ERRORCHECK,检错锁。一个线程如果对一个已经加锁的检错锁再次加锁,则加锁操作返回EDEADLK。对一个已经被其让他线程加锁的检错锁解锁,或者对一个已经解锁的检错锁再次解锁,则检错锁返回EPERM。
PTHREAM_MUTEX_RECURSIVE,嵌套锁。这种锁允许一个线程在释放锁之前对他加锁而不发生死锁。不过其他线程如果要获得这个锁,则当前锁的拥有者必须执行相应次数的解锁操作。对一个已经被其他线程枷锁的嵌套锁解锁,或者对一个已经解锁的嵌套锁再次解锁,则解锁操作返回EPERM。
PTHREAD_MUTEX_DEFAULT,默认锁。一个线程如果对一个已经加锁的默认锁再次加锁,或者对一个已经被其他线程加锁的默认锁解锁,或者对一个已经解锁的默认锁再次解锁,将导致不可预期的后果。
死锁举例
使用互斥锁的一个噩耗是死锁。死锁使得一个或多个线程被挂起而无法继续执行,而且这种情况还不容易被发现。在一个线程中对另一个已经加锁的普通锁再次加锁将导致死锁,这种情况可能出现在设计的不够仔细的递归函数中。另外,如果两个线程按照不同的顺序来申请两个互斥锁,也容易产生死锁。
如下所示便是按不同顺序访问互斥锁导致死锁的实例:
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
int a = 0;
int b = 0;
pthread_mutex_t mutex_a;
pthread_mutex_t mutex_b;
void another( void arg )
{
pthread_mutex_lock( &mutex_b );
printf( "in child thread, got mutex b, waiting for mutex a\n" );
sleep( 5 );
++b;
pthread_mutex_lock( &mutex_a );
b += a++;
pthread_mutex_unlock( &mutex_a );
pthread_mutex_unlock( &mutex_b );
pthread_exit( NULL );
}
int main()
{
pthread_t id;
pthread_mutex_init( &mutex_a, NULL );
pthread_mutex_init( &mutex_b, NULL );
pthread_create( &id, NULL, another, NULL );
pthread_mutex_lock( &mutex_a );
printf( "in parent thread, got mutex a, waiting for mutex b\n" );
sleep( 5 );
++a;
pthread_mutex_lock( &mutex_b );
a += b++;
pthread_mutex_unlock( &mutex_b );
pthread_mutex_unlock( &mutex_a );
pthread_join( id, NULL );
pthread_mutex_destroy( &mutex_a );
pthread_mutex_destroy( &mutex_b );
return 0;
}
代码中加入sleep函数来模拟连续调用pthread_mutex_lock之间的时间差,以确保代码中的两个线程各自占有一个互斥锁,然后等待另外一个互斥锁。这样,两个线程就僵持住了,谁都不能继续往下执行,从而形成死锁。如果代码中不加入sleep函数,则这段代码或许总能成功运行,从而为程序留下一了个潜在的BUG。