转载时请注明出处和作者联系方式
文章出处:http://www.limodev.cn/blog
作者联系方式:李先静 <xianjimli at hotmail dot com>
这几年并发技术受到前所未有的关注:CPU进入多核时代,连手机芯片都使用三核的CPU(AP+BP+DSP集成到一颗芯片)了。 天生具有并发能力的语言ErLang逐渐成为热点。网格和云计算开始进入实用阶段。还有一些新技术更是让我闻所未闻,初学者也不用被这些铺天盖地的名词吓 倒。据笔者的经验来看,这些技术或许能够改变产业的格局,对人类生活造成重大影响,但从实现角度来看并不无多少革命,相反大部分都是传统技术的改进和应 用。这几年我一直在研究开源的基础软件,实际上我没有发现多少“新”东西或者核心技术。要说真正的核心还是如序言中说的:战胜复杂度和应对变化。
作为系统程序员,掌握基础理论和经典的设计方法,比去追逐一些所谓的新技术要实用得多,基础打扎实了,学习新知识也是很容易的事。在接下来几节中,我们一起来学习传统的并发编程知识。在这里我们请读者完成下列任务:
了解linux下的多线程编程的基本方法,以双向链表为载体实现传统的生产者-消费者模型:一个线程往双向链表中加东西,另外一个线程从这里面取。
Linux下的多线程编程使用pthread(POSIX Thread)函数库,使用时包含头文件pthread.h,链接共享库libpthread.so。这里顺便说一下gcc链接共享库的方式:-L用来指 定共享库所在目录,系统库目录不用指定。-l用来指定要链接的共享库,只需要指定库的名字就行了,如:-lpthread,而不是 -llibpthread.so。看起来有点怪,这样做的原因是共享库通常带有版本号,指定全文件名就意味着你要绑定到特定版本的共享库上,只指定名字则 在可以运行时通过环境变量来选择要使用的共享库,这样能够给软件升级带来的方便。
pthread函数库的使用相对比较简单,读者可以在终端下运行man pthread_create阅读相关函数的手册,也可以到网上找些例子参考。具体使用方法我们就不讲了,这里介绍一下初学者常犯的错误:
o 用临时变量作为线程参数的问题。
#include <stdio.h>
#include <pthread.h>
#include <assert.h>
void* start_routine(void* param)
{
char* str = (char*)param;
printf("%s:%s/n", __func__, str);
return NULL;
}
pthread_t create_test_thread()
{
pthread_t id = 0;
char str[] = "it is ok!";
pthread_create(&id, NULL, start_routine, str);
return id;
}
int main(int argc, char* argv[])
{
void* ret = NULL;
pthread_t id = create_test_thread();
pthread_join(id, &ret);
return 0;
}
分析:由于新线程和当前线程是并发的,谁先谁后是无法预测的。可能create_test_thread 已经执行完了,str已经被释放了,新线程才拿到这参数,此时它的内容已经无法确定了,打印出的字符串自然是随机的。
o 线程参数共享的问题。
#include <stdio.h>
#include <pthread.h>
#include <assert.h>
void* start_routine(void* param)
{
int index = *(int*)param;
printf("%s:%d/n", __func__, index);
return NULL;
}
#define THREADS_NR 10
void create_test_threads()
{
int i = 0;
void* ret = NULL;
pthread_t ids[THREADS_NR] = {0};
for(i = 0; i < THREADS_NR; i++)
{
pthread_create(ids + i, NULL, start_routine, &i);
}
for(i = 0; i < THREADS_NR; i++)
{
pthread_join(ids[i], &ret);
}
return ;
}
int main(int argc, char* argv[])
{
create_test_threads();
return 0;
}
分析:由于新线程和当前线程是并发的,谁先谁后是无法预测的。i在不断变化,所以新线程拿到的参数值是无法预知的,打印出的字符串自然也是随机的。
o 虚假并发。
#include <stdio.h>
#include <pthread.h>
#include <assert.h>
void* start_routine(void* param)
{
int index = *(int*)param;
printf("%s:%d/n", __func__, index);
return NULL;
}
#define THREADS_NR 10
void create_test_threads()
{
int i = 0;
void* ret = NULL;
pthread_t ids[THREADS_NR] = {0};
for(i = 0; i < THREADS_NR; i++)
{
pthread_create(ids + i, NULL, start_routine, &i);
pthread_join(ids[i], &ret);
}
return ;
}
int main(int argc, char* argv[])
{
create_test_threads();
return 0;
}
分析:因为pthread_join会阻塞直到线程退出,所以这些线程实际上是串行执行的,一个退出了,才创建下一个。当年一个同事写了一个多线程的测试程序,就是这样写的,结果没有测试出一个潜伏的问题,直到产品运行时,这个问题才暴露出来。
访问线程共享的数据时要加锁,让访问串行化,否则就会出问题。比如,可能你正在访问的双向链表的某个结点时,它已经被另外一个线程删掉了。加锁的方 式有很多种,像互斥锁(mutex= mutual exclusive lock),信号量(semaphore)和自旋锁(spin lock)等都是常用的,它们的使用同样很简单,我们就不多说了。
在加锁/解锁时,初学者常犯两个错误:
o 存在遗漏解锁的路径。初学者常见的做法就是,进入某个临界函数时加锁,在函数结尾的地方解锁,我甚至见过这种写法:
{
/*这里加锁*/
…
return …;
/*这里解锁*/
}
如果你也犯了这种错误,应该好好反思一下。有时候,return的地方太多,在某一处忘记解锁是可能的,就像内存泄露一样,只是忘记解锁的后果更严重。像下面这个例子:
Ret dlist_insert(DList* thiz, size_t index, void* data)
{
DListNode* node = NULL;
DListNode* cursor = NULL;
return_val_if_fail(thiz != NULL, RET_INVALID_PARAMS);
dlist_lock(thiz);
if((node = dlist_create_node(thiz, data)) == NULL)
{
dlist_unlock(thiz);
return RET_OOM;
}
if(thiz->first == NULL)
{
thiz->first = node;
dlist_unlock(thiz);
return RET_OK;
}
...
dlist_unlock(thiz);
return RET_OK;
}
如果一个函数有五六个甚至更多的地方返回,遗忘一两个地方是很常见的,即使没有忘记,每个返回的地方都要去解锁和释放相关资源也是很麻烦的。在这种情况下,我们最好是实现单入口单出的函数,常见的做法有两种:
一种是使用goto语句(在linux内核里大量使用)。示例如下:
Ret dlist_insert(DList* thiz, size_t index, void* data)
{
Ret ret = RET_OK;
DListNode* node = NULL;
DListNode* cursor = NULL;
return_val_if_fail(thiz != NULL, RET_INVALID_PARAMS);
dlist_lock(thiz);
if((node = dlist_create_node(thiz, data)) == NULL)
{
ret = RET_OOM;
goto done;
}
if(thiz->first == NULL)
{
thiz->first = node;
goto done;
}
...
done:
dlist_unlock(thiz);
return ret;
}
另外一种是使用do{}while(0);语句,出于受教科书的影响(不要用goto语句),我习惯了这种做法。示例如下:
Ret dlist_insert(DList* thiz, size_t index, void* data)
{
Ret ret = RET_OK;
DListNode* node = NULL;
DListNode* cursor = NULL;
return_val_if_fail(thiz != NULL, RET_INVALID_PARAMS);
dlist_lock(thiz);
do
{
if((node = dlist_create_node(thiz, data)) == NULL)
{
ret = RET_OOM;
break;
}
if(thiz->first == NULL)
{
thiz->first = node;
break;
}
...
}while(0);
dlist_unlock(thiz);
return ret;
}
o 加锁顺序的问题。有时候为了提高效率,常常降低加锁的粒度,访问时不是用一个锁锁住整个数据结构,而是用多个锁来控制数据结构各个部分。这样一个线程访问 数据结构的这部分时,另外一个线程还可以访问数据结构的其它部分。但是在有的情况下,你需要同时锁几个锁,这时就要注意了:所有线程一定要按相同的顺序加 锁,相反的顺序解锁。否则就可能出现死锁,两个线程都拿到对方需要的锁,结果出现互相等待的情况。
在生产者-消费者的练习中,大部分人选择了由调用者来加锁:作为生产者,往双向链表里插入数据时,先加锁,插入数据,然后解锁。作 为消费者,从双向链表里取数据时,先加锁,删除数据,然后解锁。这是合理的,不过有点麻烦:每个调用者都要做这些动作,如果其中一个调用者忘记了解锁的步 骤,就会造成死锁。而且调用者必须要清楚自己是在多线程下工作,这些代码放到单线程的环境中就不能使用了。
在很多情况下由实现者来加锁是比较好的选择,那样对调用者更为友好,可以避免出现一些不必要的错误。比如像目前Linux下流行的DBUS,它是一 套进程间通信框架,它支持单线程和多线程版本,但调用者不需要明确加锁/解锁,也不需要连接不同的库或者用宏来控制,单线程版本和多线程版本的不同只是在 一个初始化函数上。
这里我们请读者对前面实现的双向链表做点改进:
o 支持多线程和单线程版本。对于多线程版本,由实现者(在链表)加锁/解锁,对于单线程版本,其性能不受影响(或很小)。
o区分单线程版本和多线程版本时,不需要链接不同的库,或者要宏来控制,完全可以在运行时切换。
o 保持双向链表的通用性,不依赖于特定的平台。
面对这个需求,一些初学者可能有点蒙了。以前在学校的时候,对于课本后面的练习,我总是信心百倍,原因很简单,我确信这些练习不管 它的出现方式有多么不同,但总是与前面学过的知识有关。记得《如何求解问题—现代启发式方法》中说过,正是这种练习的方式妨碍了我们解决问题的能力,在现 实中解决问题时通常没有这么幸运。在《系统程序员成长计划》我把练习放前面,目标就是刺激读者去思考,在学习知识的同时学习解决问题的方法。
这里我们应该怎么分析呢?要在双向链表里加锁,第一是要区分单线程和多线程,要链接同一个库,而且不能用宏来控制。第二是不能依赖于特定平台,而锁 本身恰恰又是依赖于平台的。怎么办?很明显这两个需求都要求锁的实现可以变化的:单线程版本它什么都不做,多线程版本中,不同的平台有不同的实现。
我们要做的就是隔离变化。变化怎么隔离?前面我们已经练习过几次用回调函数来隔离变化了,所有的读者都会想到这个方法,因为锁无非是具有两个功能:加锁和解锁,我们把它抽象成两个回调函数就行了。
这种方法是可行的。这里的情况与前面相比有点特殊:前面的回调函数都是些独立功能的函数,每个回调函数都有自己的上下文,而这里的多个回调函数具有 相关的功能,并且共享同一个上下文(锁)。其次是这里的上下文(锁)是一个对象,有自己的生命周期,完成自己的使命后就应该被销毁。
这里我们引入接口(interface)这个术语,接口其实就是一个抽象的概念,它只定义调用者和实现者之间的契约,而不规定实现的方法。比如这里 的锁就是一个抽象的概念,它有加锁/解锁两个功能,这是调用者和实现者之间的契约。但光有这个概念不能做任何事情,只有具体的锁才能被使用。至于具体的 锁,不同的平台有不同的实现,但调用者不用关心。正因为调用者不用关心接口的实现方法,接口成了隔离变化最有力的武器。
在这里,锁是一个接口,双向链表是锁的调用者,有基于不同方式实现的锁。通过接口,双向链表把锁的变化隔离开来:区分单线程和多线程,隔离平台相关性。在C语言中,接口的朴素定义是:一组相关的回调函数及其共享的上下文。我们看看锁这个接口怎么定义:
struct _Locker;
typedef struct _Locker Locker;
typedef Ret (*LockerLockFunc)(Locker* thiz);
typedef Ret (*LockerUnlockFunc)(Locker* thiz);
typedef void (*LockerDestroyFunc)(Locker* thiz);
struct _Locker
{
LockerLockFunc lock;
LockerUnlockFunc unlock;
LockerDestroyFunc destroy;
char priv[0];
};
这里要注意三个问题:
o 接口一定要足够抽象,不能依赖任何具体实现的数据类型。接口一旦与某个具体实现关联了,另外一种实现就会遇到麻烦。比如这里你使用了pthread_mutex_t,那你要实现一个win32下的锁怎么办呢。
o 接口不能有create函数,但一定要有destroy函数。我们说过对象有自己的生命周期,创建它,使用它,然后销毁它。但接口只是一个概念,不可能通 过这个概念凭空创建一个对象出来,对象只能通过具体实现来创建,所以接口不应该出现create自己的函数。一旦对象被创建出来,使用者应该在不再需要它 时销毁它,在销毁对象时,如果还要知道它的实现方式才能销毁它,那就造成了调用者和实现者之间不必要的耦合,因此接口都要提供一个destroy函数,调 用者可以直接销毁它。
o 这里的priv用来存放上下文信息,也就是具体实现需要用到的数据结构。像前面的回调函数一样,我们可以用一个void* ctx的成员来保存上下文信息。我们使用的char priv[0];技巧,有点额外的好处:只需要一次内存分配,而且可以分配刚好够用的长度(0到任意长度)。
前面我们使用回调函数,调用时要判断回调函数是否为空,每个地方都要重复这个动作,所以我们把这些判断集中起来好了:
static inline Ret locker_lock(Locker* thiz)
{
return_val_if_fail(thiz != NULL && thiz->lock != NULL, RET_INVALID_PARAMS);
return thiz->lock(thiz);
}
static inline Ret locker_unlock(Locker* thiz)
{
return_val_if_fail(thiz != NULL && thiz->unlock != NULL, RET_INVALID_PARAMS);
return thiz->unlock(thiz);
}
static inline void locker_destroy(Locker* thiz)
{
return_if_fail(thiz != NULL && thiz->destroy != NULL);
thiz->destroy(thiz);
return;
}
下面我们来看看基于pthread_mutex的实现:
o 在locker_pthread.h中,提供一个创建函数。
Locker* locker_pthread_create(void);
o 在locker_pthread.c中,实现这些回调函数:
定义私有数据结构:
typedef struct _PrivInfo
{
pthread_mutex_t mutex;
}PrivInfo;
创建对象:
Locker* locker_pthread_create(void)
{
Locker* thiz = (Locker*)malloc(sizeof(Locker) + sizeof(PrivInfo));
if(thiz != NULL)
{
PrivInfo* priv = (PrivInfo*)thiz->priv;
thiz->lock = locker_pthread_lock;
thiz->unlock = locker_pthread_unlock;
thiz->destroy = locker_pthread_destroy;
pthread_mutex_init(&(priv->mutex), NULL);
}
return thiz;
}
实现几个回调函数:
static Ret locker_pthread_lock(Locker* thiz)
{
PrivInfo* priv = (PrivInfo*)thiz->priv;
int ret = pthread_mutex_lock(&priv->mutex);
return ret == 0 ? RET_OK : RET_FAIL;
}
…
我简单说一下里面几个问题:
o malloc(sizeof(Locker) + sizeof(PrivInfo)); 前面的char priv[0]并不占空间,这是C语言新标准定义的,用于实现变长的buffer,它在这里的长度由sizeof(PrivInfo)决定。
o PrivInfo* priv = (PrivInfo*)thiz->priv; 这里的thiz->priv只是一个定位符,实际上等于(size_t)thiz+sizeof(Locker),帮我们定位到私有数据的内存地址上。
使用方法:
单线程版本:
DList* dlist = dlist_create(NULL, NULL, locker_pthread_create());
多线程版本:
DList* dlist = dlist_create(NULL, NULL, NULL);
接口在软件设计中占有非常重要的地位,它是隔离变化和降低复杂度最有力的武器,差不多所有的设计模式都与接口有关。后面我们会反复的练习,这里请读者仔细体会一下。
本节示例代码请到这里下载。
嵌套锁与装饰模式
在生产者-消费者的练习中,当由双向链表的实现者负责加锁时,一般都会遇到莫名其妙的死锁问题。有的读者可能已经查出来了原因是嵌套的加锁。比如在 dlist_insert中调用了dlist_length,进入dlist_insert时已经加了一次锁,再调用dlist_length时又加了一 次锁,这时就出现了死锁问题。
初学者遇到这个问题的时候,通常的做法是在调用dlist_length之前先解锁,调用完dlist_length后再重新加锁。这样是存在问题的:一个原子操作变成了几个原子操作,数据完整性得不到保证,在你重新加锁之前,其它线程可能利用这个空隙做了些别的事情。
有效解决这个问题的办法有两个,其一是实现一个内部版本的dlist_length,它在里面不加锁。其二是使用嵌套锁,允许同一个线程多次加锁。 pthread有嵌套锁的实现,不过我们在这里不用它,原因是我们要提供一个更通用的解决方案。现在我们不再满足于实现一个双向链表,而是要实现一个跨平 台的基础函数库。
在这里我们请读者实现一个嵌套锁,要求如下:
o 嵌套锁仍然兼容Locker接口。
o 嵌套锁的实现不依赖于特定平台。
嵌套锁与装饰模式
嵌套锁的实现算法
加锁:
o如果没有任何线程加锁,就直接加锁,并且记录下当前线程的ID。
o如果是当前线程加过锁了,就不用加锁了,只是将加锁的计数增加一。
o如果其它线程加锁了,那就等待直到加锁成功,后继步骤与第一种情况相同。
解锁:
o如果不是当前线程加的锁或者没有人加锁,那这是错误的调用,直接返回。
o如果是当前线程加锁了,将加锁的计数减一。如果计数大于0,说明当前线程加了多次,直接返回就行了。如果计数为0,说明当前线程只加了一次,则执行解锁动作。
这个逻辑很简单,要做到兼容Locker的接口和平台无关,我们还需要引入装饰模式这个概念。装饰模式的功能是在于不改变对象的本质(接口)的前提 下,给对象添加附加的功能。和继承不同的是,它不是针对整个类的,而只是针对单个对象的。装饰这个名字非常直观的表现它的意义:在你自己的显示器上做点了 装饰,比如贴上一张卡通画:第一是它没有改显示器的本质,显示器还是显示器。第二是只有你自己的显示器上多了张卡通画,其它显示器没有影响。
这里我们要对一把锁进行装饰,不改变它的接口,但给它加上嵌套的功能。下面我们看看在C语言里的实现方法:
o 创建函数的原型。由于获取当前线程ID的函数是平台相关的,我们要用回调函数来抽象它。
typedef int (*TaskSelfFunc)(void);
Locker* locker_nest_create(Locker* real_locker, TaskSelfFunc task_self);
这里可以看出:传入的是一把锁,返回的还是一把锁,没有改变的接口,但是返回的锁已经具有嵌套调用的功能了。
o 嵌入锁的实现。
私有信息:拥有锁的线程ID、加锁的计数,被装饰的锁和获取当前线程ID的回调函数。
typedef struct _PrivInfo
{
int owner;
int refcount;
Locker* real_locker;
TaskSelfFunc task_self;
}PrivInfo;
1.实现加锁函数:如果当前线程已经加锁,只是增加加锁计数,否则就加锁。
static Ret locker_nest_lock(Locker* thiz)
{
Ret ret = RET_OK;
PrivInfo* priv = (PrivInfo*)thiz->priv;
if(priv->owner == priv->task_self())
{
priv->refcount++;
}
else
{
if( (ret = locker_lock(priv->real_locker)) == RET_OK)
{
priv->refcount = 1;
priv->owner = priv->task_self();
}
}
return ret;
}
2.实现解锁函数:只有当前线程加的锁才能解锁,先减少加锁计数,计数为0时才真正解锁,否则直接返回。
static Ret locker_nest_unlock(Locker* thiz)
{
Ret ret = RET_OK;
PrivInfo* priv = (PrivInfo*)thiz->priv;
return_val_if_fail(priv->owner == priv->task_self(), RET_FAIL);
priv->refcount--;
if(priv->refcount == 0)
{
priv->owner = 0;
ret = locker_unlock(priv->real_locker);
}
return ret;
}
o使用方法。除了创建方法稍有不同外,调用方法完全一样。
Locker* locker = locker_pthread_create();
Locker* nest_locker = locker_nest_create(locker, (TaskSelfFunc)pthread_self);
DList* dlist = dlist_create(NULL, NULL, nest_locker);
装饰模式最有用的地方在于,它给单个对象增加功能,但不是影响调用者,即使加了多级装饰,调用者也不用关心。
本节示例代码请到这里下载。