线程小记

线程概念

典型的UNIX进程可以看作只有一个控制线程,任务的执行只能串行来做。有了多个控制线程后,就可以同时做多个事情。
每个线程都包含执行环境所需要的信息:

  • 线程id
  • 寄存器值
  • 调度器优先级和策略
  • 信号屏蔽字
  • errno变量
  • 线程私有变量

一个进程的可执行程序代码、程序的全局内存和堆内存、栈以及文件描述符对所有的线程共享。
本文讨论的线程接口来自POSIX.1-2001。接口也称为"pthread"或者"posix线程"。

线程标识

类似进程ID,每个线程有一个在进程上下文中唯一的ID值。
进程ID使用pid_t数据类型来表示,是一个非负整数。线程ID是用pthread_t数据类型来表示。
比较两个线程id,相等,返回非0数值,否则,返回0

#include <pthread.h>
int pthread_equal(pthread_t tid1, pthread tid2);

线程也可以通过pthread_self函数获取自身的线程id

pthread_t pthread_self(void);

线程创建

在POSIX线程的情况下,程序开始运行时,它是以单个控制线程运行的。新增的线程可以通过调用pthread_create函数创建。

#include <pthread.h>
int pthread_create(pthread_t *restrict tidp, 
                   const pthread_attr_t *restrict attr,
                    void *(*start_rtn) (void *), void *restrict arg);

新创建的线程ID会被设置为tidp指向的内存单元。attr参数用于定制各种不同的线程属性。
新创建的线程从start_rtn函数的地址开始运行,该函数只有一个无类型指针参数arg。
线程创建时不能保证哪个线程先运行:是新创建的线程,还是调用线程。

实例

如下程序,创建了一个线程,打印了进程ID、新线程ID以及初始线程的ID。

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
pthread_t tid1;

void printtids(const char *s) {
  pid_t pid;
  pthread_t tid;

  pid = getpid();
  tid = pthread_self();
  printf("%s pid %lu tid %lu (0x%1x)\n", s, (unsigned long)pid, (unsigned long)tid, (unsigned long)tid);
}

void *thr_fn(void *arg) {
  printtids("new thread: ");
  return ((void *)0);
}

int main() {
  int err = 0;
  err = pthread_create(&tid1, NULL, thr_fn, NULL);
  if (0 != err) {
    //err_exit(err, "can't create thread");
  }
  printtids("main thread:");
  sleep(1);
  exit(0);
}

这个程序有两个地方需要注意:

  • 一个是主线程需要休眠,不然新线程可能来不及执行进程就结束了。这种行为依赖于操作系统的线程实现和调度算法。
  • 第二个地方是新线程通过pthread_self获取自己的线程ID,而不是直接使用tid1从共享内存中读出。这是因为如果新线程在主线程调用pthread_create返回之前就运行了,那么新线程看到的是未经初始化的tid1的内容。

运行结果:

main thread: pid 91334 tid 4708181440 (0x18a125c0)
new thread:  pid 91334 tid 123145310978048 (0x844000)

如我们期望的,进程ID相同,线程ID不同。

线程终止

进程中的任意线程调用了exit、_Exit或者_exit,整个进程就会终止。
单个线程可以通过三种方式退出:

  1. 线程可以从启动实例中返回,返回值是线程的退出码。
  2. 线程可以被同一进程中的其它线程取消。
  3. 线程调用pthread_exit。
#include <pthread.h>
void pthread_exit(void *rval_ptr)

rval_ptr是一个无类型指针,与传递给启动实例的单个参数类似。进程中的其它线程可以通过pthread_join函数访问到这个指针。

调用pthread_join的线程会一直阻塞直到线程以上述三种方式退出。如果线程以第一种方式退出,rval_ptr中就包含退出码。以第二种方式rval_ptr指向的内存单元中就设置未PTHREAD_CANCELED。

如果对线程的返回值不感兴趣,可以把rval_ptr设置未NULL。调用线程会等待指定的线程终止,但不获取线程的终止状态。

pthread_create和pthread_exit函数的无类型指针参数可以包含复杂的结构的地址,但是这个结构所使用的内存要使用malloc或者为全局变量,必须确保在调用者完成调用后内存仍然是有效的。比如线程在自己的栈上分配了一个结构,然后把指向这个结构的指针传递给pthread_exit,那么pthread_join试图使用该结构时就可能出错。

线程可以通过pthread_cancel函数来请求取消同一进程中的其它线程。

#include <pthread.h>
void pthread_cancel(pthread_t tid)

默认情况下,pthread_cancel函数会使得tid标识的线程如同调用了参数为PTHRED_CANCEL的pthread_exit函数。但是线程可以选择忽略取消或者控制如何被取消。pthread_cancel并不等待线程终止,仅仅提出请求

线程可以安排它退出时的清理函数,这样的函数称为线程清理程序,可以有多个,被记录在栈中,也就是说它们的执行顺序与注册顺序相反。

#include <pthread.h>
void pthread_cleanup_push(void (*rtn)(void *), void *arg);
void pthread_cleanup_pop(int execute)

当线程执行以下动作时,清理函数rtn由pthread_cleanup_push函数调度的,调用时只有一个参数arg:

  1. 调用pthread_exit
  2. 相应取消请求
  3. 用非零execute参数调用pthread_cleanup_pop,为0则不调用清理函数,依旧会pop

线程如果是从启动实例中退出的(return ((void *)x)),将不会调用清理函数。

进程和线程函数之间的相似之处总结:

  • fork/pthread_create 创建新的控制流
  • exit/pthread_exit 从现有控制流中退出
  • waitpid/pthread_join 从控制流中得到退出状态
  • atexit/pthread_cancel_push 注册在退出控制流时调用的函数
  • getpid/pthread_self 获取控制流的id
  • abort/pthread_cancel 请求控制流的非正常退出

默认情况下,线程的终止状态会保存直到对线程调用pthread_join函数。如果线程被分离,线程的底层资源可以在线程终止时立即收回。这时不可以使用pthread_join等待它的终止状态,会产生未定义行为,可以调用pthread_detach分离线程。

#include <pthread.h>
void pthread_detach(pthread_t tid)

线程同步

当多个线程控制共享内存时,要确保每个线程看到一致的视图。如果每个线程修改的变量是其它线程不会读取和修改的,比如栈上的变量,就不会出现不一致的问题。如果线程修改一个其它线程也可以读取和修改的变量时,我们就需要对这些线程进行同步,确保它们在访问变量时不会读到无效的值。

在变量修改时间超过一个存储器访问周期的处理结构中,当存储器读与存储器写这两个周期交叉时,不一致就会出现。为了解决这个问题,线程不得不实用锁,同一时间只允许一个线程访问该变量。

两个或多个线程修改变量时也需要进行同步。增量操作通常分为以下步骤:

  1. 从内存单元读入寄存器
  2. 在寄存器中做增量操作
  3. 把新的值写回内存单元

如果修改操作是原子操作,就不存在竞争。在现代计算机系统中,存储访问需要多个总线周期,多处理器的总线周期通常在多个处理器上是交叉的,所以并不能保证数据是顺序一致的。

互斥量

互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量加锁,在访问完成后释放。互斥量加锁后,其它线程试图再次对其加锁都会被阻塞直到锁被释放。

互斥变量是用pthread_mutex_t数据类型表示的,在使用之前必须要进行初始化,可以设为常量PTHREAD_MUTEX_INITIALIZER(只适用于静态分配的互斥量)。也可以通过pthread_mutex_init函数来进行初始化,如果动态分配互斥量,在释放内存前需要调用pthread_mutex_destroy。

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
                       const pthread_mutexattr_t *restrict attr)
int pthread_mutex_destroy(pthread_mutex_t *mutex)

attr设为NULL时使用默认的属性初始化。

互斥量的加锁,解锁。

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex *mutex)
int pthread_mutex_trylock(pthread_mutex *mutex)
int pthread_mutex_unlock(pthread_mutex *mutex)

使用pthread_mutex_lock对互斥量加锁,如果互斥量已经上锁,则调用线程会一直阻塞直到互斥量被解锁。如果不希望调用线程阻塞,可以使用pthread_mutex_trylock尝试对互斥量加锁,互斥量已经上锁时会失败,返回EBUSY。

避免死锁

当线程试图对一个互斥量加锁两次,就会出现死锁。使用一个以上的互斥量时,如果线程A占有互斥量1,试图锁住互斥量2,而线程B占有互斥量2,试图占有互斥量1,两个线程都在相互请求另一个线程拥有的资源,于是产生死锁。
应用程序需要仔细控制加锁顺序来避免死锁的发生,死锁只会发生在一个线程试图锁住另一个线程以相反的顺序锁住的互斥量。
锁的粒度较粗,比较容易编写无死锁的程序,但是会出现很多线程阻塞等待锁,性能较差,而锁的粒度较细,会极大增加编程的难度和出现死锁等问题的风险,实际编程中需折中考虑。

当试图获取一个已加锁的互斥量时,pthread_mutex_timedlock允许绑定线程等待时间。pthread_mutex_timedlock与pthread_mutex_lock基本是等价的。
在达到超时时间时,pthread_mutex_timedlock返回错误码ETIMEDOUT。

#include <pthread.h>
#include <time.h>
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
                            const struct timespec *restrict tsptr);

超时时间指绝对时间,而非相对时间。

读写锁

读写锁与互斥量类似,互斥量只有加锁中与未加锁两种状态,一旦被线程加锁占有,其它线程只能等待。而读写锁有更多状态:未加锁、读模式加锁和写模式加锁。一次只有一个线程可以进行写模式加锁,而多个线程可以同时占有读模式的读写锁。

当读写锁以写模式加锁时,所有试图对对线程加锁的线程都会被阻塞。当读写锁以读模式加锁时,所有以读模式对它加锁的线程都可以得到访问权,所有以写模式尝试对它加锁的线程将会被阻塞。当读写锁以读模式加锁中时,这时有一个线程试图以写模式加锁,读写锁通常会阻塞后面的读模式锁请求,这样可以避免读模式锁长期占有,而写模式锁请求一直在等待。

读写锁非常适合读请求远大于写请求次数的场景。读写锁也称作共享互斥锁(sharded-exclusive lock),当以读模式锁住,可以称为以共享模式锁住的,反之可以认为是以互斥模式锁住的。

与互斥量相比,读写锁在使用前必须初始化,在释放它们底层内存之前必须销毁。

#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
                        const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock)
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); // 以读模式加锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); // 以写模式加锁
nt pthread_rwlock_unlock(pthread_rwlock_t *rwlock); // 解锁

// 读写锁加锁的条件版本
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); // 以读模式加锁
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); // 以写模式加锁

各种实现可能对共享模式下获取读写锁的次数有限制,所以要检查函数的返回值。

带有超时的读写锁

#include <pthread.h>
#include <time.h>
int pthread_rwlock_timerdlock(pthread_rwlock_t *restrict rwlock,
                              const struct timespec *restrict tsptr); 
int pthread_rwlock_timewdlock(pthread_rwlock_t *restrict rwlock,
                              const struct timespec *restrict tsptr); 

条件变量

条件变量给多线程提供了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定条件的发生。
使用条件变量之前必须对它进行初始化,可以把常量PTHREAD_COND_INITIALIZER赋给静态分配的条件变量,如果条件变量是动态分配的,则需要使用pthread_cond_init进行初始化,使用pthread_cond_destroy释放资源。

#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond,
                      const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);

使用pthread_cond_wait等待条件变为真。

#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
                      pthread_mutex_t *restrict mutex);
// 带有时间条件的版本
int pthread_cond_timewait(pthread_cond_t *restrict cond,
                          pthread_mutex_t *restrict mutex,
                          const struct timespec *restrict tsptr); 

传递给pthread_cond_wait中的互斥量对条件进行保护。调用者把互斥量传给函数,函数把调用线程放到等待条件的线程列表上,对互斥量解锁,这两步必须是原子的。pthread_cond_wait返回时,互斥量再次被锁住。

两个函数用于通知线程条件已经满足。

//唤醒至少一个等待该条件的线程。
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒所有等待该条件的线程
int pthread_cond_broadcast(pthread_cond_t *cond);

使用条件变量实现简单生产者-消费者模型示例:

#include <pthread.h>

struct msg {
    struct msg *m_next;
    /*more stuff here*/
}

struct msg *workq;
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITALIZER;

void process_msg(void)
{
    struct msg *mp;
    for (;;) {
        pthread_mutex_lock(&qlock);
        while (NULL == workq) {
            pthread_cond_wait(&qready, &qlock);
        }
        mp = workq;
        workq = mp->next; // 消费掉workq
        pthread_mutex_unlock(&qlock);
        /*now process the message mp*/
    }
}

void enqueue_msg(struct msg *mp)
{
    pthread_mutex_lock(&qlock);
    mp->next = workq;
    workq = mp;
    pthread_mutex_unlock(&qlock); // 与cond signal的顺序问题
    pthread_cond_signal(&qready);
}

条件是工作队列的状态。使用互斥量保护条件,在while条件中,把消息放到工作队列中需要占有互斥量,等待条件变量时不需要互斥量,上述提到这两步必须是原子的,假如先放到了工作队列中,这时生成者线程发送信号唤醒该线程,wait函数返回需要加锁,就会加锁两次造成死锁;如果顺序反了,先释放了锁,然后发送信号时线程未在条件列表中,造成一直wait的问题。
这里涉及到pthread_mutex_unlock与pthread_cond_signal的执行顺序问题,是先放锁呢还是先发送信号?

  1. 先unlock
    unlock之后有线程作了条件判断并消费了这个workq,之后signal的发送是无效的。而且互斥量有可能被其它低优先级的线程获得。
  2. 先singal再unlock
    线程被唤醒后获取不到互斥量再次进入sleep,浪费cpu资源。在linux实现中,有两个队列:cond_wait和lock_wait。cond signal只是将线程从cond wait中移到lock wait队列中,不用返回用户空间,所以不会有性能损耗。所以这种顺序一般是linux下采用的方式。

自旋锁

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容