有经常关注我这个博客的同学可能知道,最近我在看一些 Python 的源代码,而且之前有写一些文章介绍一些 Python 的内部实现的,可能你们会感觉有点断更的迹象。事实上,我一直是想更的,但是,我觉得我遇到了瓶颈,因为我发现解释器的关联太深了,基本上可以说是整个 Python 的核心所在,涉及得东西太多,我怕我一下子很难总结出清晰易懂的脉络来,所以就多耗费了一些时间,渐渐有了一些打算。

这篇文章不是讲 Python 源码的,但是对于我后面讲 Python 源码有很重要的作用,因为我们知道在 Python 中,最大的诟病就是 GIL,全称不知道大家还记得不 —— 全局解释器锁!一说到是锁,大家可能就能关联得想到多线程,所以本文就来讲讲 Linux 中多线程的一些东西,我觉得这篇博文的内容还是比较丰富的,我将主要参考 《APUE》和 《UNP》这两本书,再结合一点点 《CSAPP》的知识进行介绍。

Linux 线程介绍

可能对于一个初级的Linux 开发工程师(例如我)都知道,在 Linux 下,想要不阻塞得完成一个阻塞操作(例如 socket 操作/IO操作)早期的常见用法是用 fork,也就是我们所谓的多进程模型,但是,渐渐得人们发现,多进程太重了,因为受过 CS 专业课教育的我们知道在 OS 里面,进程都是有独立的进程空间的,而且 size 都是很大的,你从一个进程 fork 出另一个进程,首先得先复制进程空间,然后再执行新的操作,这个过程代价很高。

所以,有问题肯定会有一些解决办法,只是漂亮不漂亮而已,一个比较简单的解决办法就是 COW(Copy-on-write),在一定程度上缓解了这个问题,当然,也有人采用 pre-fork 的形式来避免执行时 fork 的开销。但是,这些方案都不是很好,没有很好的解决问题,所以后面就有了线程,最初的 Linux 是不支持线程的,好像是在 2.6 的内核里面才真正支持的。

那么线程相对于进程有啥好处呢?有啥联系呢?在传统的 OS 中,每个进程都有一个地址空间,在这个地址空间中,经常准并行运行多个控制线程,这些线程就像分离的微进程,但是共享着进程的地址空间,每个线程都独立完成一个完整执行序列(可能是一样的),而且线程也是系统内核调用的。所以,在多核的机器上,多线程是真的有可能并行起来的。此外,因为进程之间关联不多,所以要想在不同进程之间同步一些数据,比较麻烦,而且比较重,例如管道/消息队列/共享内存这一些,而线程就方便啦,因为进程内的全局变量都可以作为共享数据进行操作,而且开销也小,有个缺点就是需要关注同步的问题。

进程内线程间共享的内容:

然后每个线程独立的数据有:

在 Linux 里面,好像以前是有两套以上的线程 API 的,不过现在好一些,我看过两三个流行的项目里面用的都是以 pthread_ 开头的 POSIX API,而且前面提到的几本经典里面也是在讲 POSIX API,所以这篇文章就用的是 POSIX 线程标准。

Posix 线程 API

线程的操作没有很多,基本的创建/退出等就差不多了,例如这几个:

  1. #include <pthread.h>
  2. # 创建线程
  3. int pthread_create(pthread_t* thread,
  4. const pthread_attr_t* attr,
  5. void* (*start_routine) (void*),
  6. void* arg);
  7. # 线程主动结束
  8. void pthread_exit(void* retval);
  9. # 取消一个线程
  10. int pthread_cancel(pthread_t thread);
  11. # 回收线程
  12. int pthread_join(pthread_t thread, void* *retval);

如果想要一些更丰富的功能,那么可以加上这几个:

  1. #include <pthread.h>
  2. # 获取当前线程标示
  3. pthread_t pthread_self(void);
  4. # 分离线程
  5. int pthread_detachpthread_t tid);

关于这些 API 的使用我觉得就不用多说了,毕竟只是 API 的使用而已,给一个简单的例子吧,在这篇文章的 CodeExample 里面还有好几个示例,有兴趣的同学可以自信查看:

线程属性

在 POSIX API 的时候我们发现创建线程的时候还可以指定线程的属性,有点厉害,首先我们需要先来看看都支持哪些属性,这个对我们编写特定目的的线程应该会颇有帮助:

看上去没啥,但是,可以发现第二个字符数组就是深坑了,这里啥都能放啊!还是先来看看怎么设置属性吧,pthread 提供了超多的 API,打开 pthread.h 看一下:

除了 init 和 destroy 之外,其他都是 set 和 get 的,然后这些属性大概有分离状态调度相关的栈相关的,我随便找一个尝试一下看看默认的值是啥,就以 stacksize 为例吧:

有一点让我感到有意思的是,这段代码的输出是:

对比一下 ulimit -s 之后发现小了好多好多,这里涉及的问题好像比较复杂,我的理解是这个线程的 stack size 是最小的分配空间,也就是说这里是 512K,系统必须提供比 512K 更多的栈空间给我这个线程;而 ulimit -s8MB 是单个线程中函数栈的最大嵌套深度,如果嵌套超过这个 size,那么就要报 Stackoverflow 了。

线程同步

说到多线程,那么这个问题肯定是避不开的,因为无论是主动还是被动得,我们都会遇到线程同步的问题。为什么这么说呢,即使我们自己信心满满,对自己写的多线程代码很满意,不会有线程冲突,但是,我们总是会用到各种库的,那么这些库里你能保证是线程安全么?如果你能保证线程安全,那么你知道它是怎么保证的么,会影响到线程的效率么?这些我认为都是多线程需要考虑的问题,所以在 Linux 里面,函数就会分线程安全的函数和非线程安全的函数。

想要判断一个函数是不是线程安全我觉得不是一件容易的事情,但是,还是有迹可循,如果满足这几个条件之一,那么这个函数就不是线程安全的:

下面,就写一个因为线程同步不到位而产生错误的代码:

这里的我开了一个线程用于往数组中添加数据,然后开了三个线程从数组中拿数据,拿数据的代码是 Line13 - Line21,这里可能有同学觉得这里不必要使用两个变量么?一个变量不是也可以达到同样的目的?既可以计数,又可以做下标,这是没问题的,但是我为了放大这个问题,所以让步骤稍微多一步,在查看自信结果之前,你可能会以为最后的 final count = 的结果是 10000,然而,事实上并不是,有可能是 10000/10001/10002,还可能更多,不过概率小很多。这就是没有数据同步好的情况了。

在 Linux 中,线程同步的方式主要有两种,或者说三种,分别是:

信号量在使用上和互斥锁差距不大,使用方法我就只讲互斥锁了,下面就分别介绍一下这些方式:

信号量

信号量不仅仅是线程同步的方式,还是进程同步的方式,所以,很多人在讲多线程同步的时候就不说了。信号量和互斥锁有点类似,甚至于说当信号量为 1 时,基本上就是互斥锁了,但是,它们从本质上还是有区别的:

对于信号量,我记忆中最深的应该是 Dijkstra 的 PV 操作,不过后面为了更好理解,称为 sleep 和 wakeup 比较多。

互斥锁

互斥锁可能是我们最经常听到的,也就是一个 Lock,如果你获得了这个锁,你就拥有了临界区的执行权,否则,要么你等,要么你避开。互斥锁是一个严格的只有一把,要么给你,要么给我,有的时候我们可能希望能稍微宽松些,所以还有一种稍微宽松一点的 读写锁,当 读写锁 被读占用时,其他人想要 读 都是可以的,就是 写 不行;当 读写锁 被写占用时,其他人想要 读/写 都不行,只能干等。

Posix 的 Mutex 的操作函数主要有这几个:

  1. #include<pthread.h>
  2. int pthread_mutex_init(pthread_mutex_t* mutex,const pthread_mutexattr_t* mutexattr);
  3. int pthread_mutex_destroy(pthread_mutex_t* mutex);
  4. int pthread_mutex_lock(pthread_mutex_t* mutex);
  5. int pthread_mutex_trylock(pthread_mutex_t* mutex);
  6. int pthread_mutex_unlock(pthread_mutex_t* mutex);

作用应该都可以很简单得从名字中看出来,所以,下面我就以这组 API 为例,对刚才的例子进行改写一下:

这里在 Line 6,Line 16,Line 23 这高亮的 3 行做了锁的处理,我这里是将 readIdx 作为临界区,只要控制住了这个变量,也就随之控制住了 readCount 这个量。

条件变量

回顾一下我们前面的示例,我们会发现一个问题,那就是如果当 3 个消费线程消费太快的时候,经常会遇到数组里面还没有插入值,然后我们又要访问了,那么这就是 条件变量 可以利用的地方了。

条件变量 可以让等待某个变量的线程进入睡眠列表,然后再变量的条件 ok 的时候被唤醒继续,例如当数组不够的时候,消费线程就进入睡眠,而不是在 忙轮询;然后有生产了,再醒来处理。这也是条件变量的一个重要作用:防止忙轮询,下面就对这个例子做一下更改,顺便修复一下 BUG:

当生产的线程创建了一个元素之后,就唤醒一下消费线程可以醒来了,注意,这里是使用的唤醒一个线程,而不是所有线程。然后,最后还需要注意的就是,当消费完所有元素之后,还有两个线程在等着,需要把它们唤醒,然后才能结束。

线程深入

在使用 Python 的时候,我对一个概念非常感兴趣,那就是 ThreadLocal 数据,不仅仅在 Python 中,很多语言中都有,例如 Java 中也是有的,我当初的想法是这个不难实现,顶多在进程空间里面维护一个 Map,然后 key 可以是 变量 + 线程ID 的形式,然后 value 就是对应的 ThreadLocal 值了。不过事实上,在 Linux 中,是有这么一个 Map,但是却不是在进程中,或者说不仅仅在进程中,还在线程中。

在 Linux 中,系统为每个进程维护了一个称之为 Key 结构 的结构数组,每个 Key 结构 包含一个 标示析构函数指针,就像这样(图片来自 UNPv1):

除了这个结构之外,系统还在进程内维护了关于每个线程的多条信息,我们过滤多余的信息,看最后的一些位置:

这个长度和前面的长度一致,然后当线程调用 pthread_key_create 的时候,系统会在进程里面的表中搜索第一个不在使用的元素,然后返回它的索引(0-127)作为 key,然后这个 key 就可以在线程的列表每个线程里面对应到不同的指针,而 真正的内容 就存放在这个指针对应的位置!

这样,我们就把数据和线程关联起来,但是,还有一个问题,那就是一般这个指针对应的区域都是动态分配的,当我们回收线程的时候,系统是不会自动释放的,除非你结束了整个进程。

这个问题就是最开始提到的 析构函数 来解决的,当一个线程终止时,系统将扫描这个线程的 pkey 数组,为每个非空的 pkey 数组调用相应的 析构函数,从而释放内存。相关的函数有:

  1. #include <pthread.h>
  2. int pthread_once(pthread_once_t *onceptr, void (*init)(void);
  3. int pthread_key_create(pthread_key_t *keyptr, void (*destructor)(void *value));
  4. void *pthread_getspecific(pthread_key_t key);
  5. int pthread_setspecific(pthread_key_t key, const void *value);

Reference

  1. 栈大小和内存分部问题
  2. Lock, mutex, semaphore… what’s the difference?
  3. POSIX thread (pthread) libraries
  4. 有了互斥量,为什么还需要条件变量?