统程序员成长计划-并发

 转载时请注明出处和作者联系方式
文章出处: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);
装饰模式最有用的地方在于,它给单个对象增加功能,但不是影响调用者,即使加了多级装饰,调用者也不用关心。

本节示例代码请到这里下载。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值