0%

Linux系统编程-线程


专注于用户空间的系统级编程–即内核之上的所有内容。

线程是指在单个进程内,多路并行执行的创建和管理单元。由于线程引入了数据竞争和死锁,相关的编程错误不计其数。

进程和线程

现代操作系统包括了两种对用户空间的基础的虚拟抽象:虚拟内存和虚拟处理器
它们使进程认为自己独占机器资源。

虚拟内存为每个进程提供独立的内存地址空间,该内存地址连续映射到物理内存上。
虚拟处理器使得进程“感觉”只有自己正在运行,操作系统对其“隐藏”了事实:多个进程在可能多个处理器上以多任务方式同时运行。

虚拟内存是和进程相关的,与线程无关。
因此,每个进程有独立的内存空间,而进程中的所有线程共享这份空间。

虚拟处理器是和线程相关的,与进程无关。每个线程都是可独立调度的实体,支持单个进程每次“处理”多个操作。

和进程一样,线程也“感觉”自己独占一个处理器。
但是,和进程不一样的是,线程没有“感觉”自己独占内存–进程中的所有线程共享内存空间。

多线程

进程是正在运行的程序的抽象。但是,为什么要分离执行单元,引入线程?多线程机制有以下优点:

  • 编程抽象:将工作分为多个模块,每个模块分配一个执行单元(线程)。
    利用这种方法的设计模式包括“每个连接一个线程”和线程池模式
  • 并发性:对多处理器提供了实现“真正并发”的高效方式。
    每个线程有自己的虚拟处理器,作为独立的调度实体,可以在多处理器上运行多个线程,从而提高系统吞吐量
  • 提高响应能力:单线程进程中,一个长时间运行的任务会影响应用对用户输入的响应,多线程中可以有一条线程响应用户输入
  • I/O阻塞:单线程中,I/O阻塞会影响整个进程
  • 上下文切换:线程间切换的代价显著低于进程间的上下文切换
  • 内存保存:线程提供了一种可以共享内存,并同时利用多个执行单元的高效方式

上下文切换:线程的一大性能优势是同一进程内的线程之间切换代价很低。
在任何系统上,进程内切换的代价低于进程间切换,前者通常是后者的一部分。
在非Linux系统上,这种差别非常明显,进程间通信(IPC)代价非常高。
因此,很多系统上,称线程为“轻量级进程”。在Linux中,进程间切换代价并不高,而进程内切换的成本接近于0:
接近进入和退出内核的代价。进程的代价不高,但是线程的代价更低。

线程模型

在系统上实现线程模型的方式有好几种,因内核和用户空间提供的支持而有一定程度的级别差异。

最简单的是内核为线程提供了本地支持的情况,每个内核线程直接转换成用户空间的线程。
这种模型称为“1:1线程模型”,也称为“内核级线程模型”。
在Linux中只是简单的将线程实现成能够共享资源的进程。
线程库通过系统调用clone()创建一个新的线程,返回的“进程”直接作为用户空间的线程。

“用户级线程模型”是“N:1线程模型”,一个N个线程的进程只会映射到一个内核进程。

“混合式线程模型”是“N:M线程模型”,上述两中的综合,但是模型过于复杂。

Linux上普遍的线程模型是第一种。

线程模式

创建多线程应用最重要的一步是确定线程模式,线程模式也是应用程序的逻辑处理和I/O模式,
可能存在很多抽象和实现细节,但两个核心的编程模式是:

  • 每个连接对应一个线程(thread-per-connection)
  • 事件驱动(event-driven)

并发性、并行性和竞争

并发性与并行性??

竞争条件是指由两个或多个线程对共享资源的非同步访问而导致错误的程序行为。
共享资源可以是系统硬件、内核资源或内存中的数据。后者最常见,称为数据竞争(date race)。
竞争发生的窗口–需要同步的代码–成为“临界区”。

同步,互斥和死锁

最常用来完成同步,实现临界区原子操作的技术是锁(lock),在Pthreads中称之为“互斥(mutexes)”。

锁住数据,而不是代码,当锁和代码关联起来时,锁的语义就很难理解。
随着时间变化,锁和数据之间的关系就不会很清晰,程序员会为数据引入新的使用方式,而不会包含相应的锁。
把锁和数据关联,这个映射就会很清晰。

互斥的引入会带来编程BUG:死锁。

避免死锁需要从一开始的设计中为多线程设计好锁的机制。
互斥体应该和数据关联,而不是和代码关联,从而有清晰的数据层。
针对死锁(ABBA)问题,需要明确互斥获取的顺序。

Pthreads

Linux内核只为线程的支持提供了底层原语,比如clone()系统调用。
POSIX对线程库进行了标准化,称之为POSIX线程,简称为Pthreads。
是UNIX系统上C/C++语言的主要线程解决方案。

Pthread API

Pthreads是由glibc提供,存在于独立库libpthread中,使用时需要显示链接:

gcc -Wall -Werror -pthread b.c -o b

Pthread API在文件<pthread.h>中定义,每个函数前缀都是pthread_,可以分为两大类:
线程管理(创建、销毁、连接和detach线程等)同步(互斥、条件变量等)

#include <pthread.h>

int pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*func) (void *), void *arg);
int pthread_join (pthread_t tid, void ** status);
pthread_t pthread_self (void);
int pthread_equal(pthread_t t1, pthread_t t2);
int pthread_detach (pthread_t tid);
void pthread_exit (void *status);
int pthread_cancel(pthread_t thread);

int pthread_mutex_lock(pthread_mutex_t * mptr); 
int pthread_mutex_unlock(pthread_mutex_t * mptr); 

int pthread_cond_wait(pthread_cond_t *cptr, pthread_mutex_t *mptr); 
int pthread_cond_signal(pthread_cond_t *cptr); 
int pthread_cond_broadcast (pthread_cond_t * cptr);
int pthread_cond_timedwait (pthread_cond_t * cptr, pthread_mutex_t *mptr, const struct timespec *abstime);

pthread_create用于创建一个线程,成功返回0,否则返回错误码(不使用errno)。

  • pthread_t tid:线程id的类型为pthread_t,通常为无符号整型,当调用pthread_create成功时,通过tid指针返回。
  • const pthread_attr_t *attr:指定创建线程的属性,如线程优先级、初始栈大小、是否为守护进程等。
    可以使用NULL来使用默认值,通常情况下我们都是使用默认值。
  • void (func) (void *):函数指针func,指定当新的线程创建之后,将执行的函数。
  • void *arg:线程将执行的函数的参数。如果想传递多个参数,请将它们封装在一个结构体中。

pthread_join用于等待某个线程退出,成功返回0,否则返回错误码(不使用errno)。
成功调用时,调用线程会被阻塞,直到由tid指定的线程终止,用于线程同步。

  • pthread_t tid:指定要等待的线程ID
  • void ** status:如果不为NULL,那么线程的返回值存储在status指向的空间中(这就是为什么status是二级指针的原因!这种才参数也称为“值-结果”参数)。

默认情况下,线程是创建成可join的。
pthread_detach用于是指定线程变为分离状态(不可join),就像进程脱离终端而变为后台进程类似。
成功返回0,否则返回错误码(不使用errno)。变为分离状态的线程,如果线程退出,它的所有资源将全部释放。
而如果不是分离状态,线程必须保留它的线程ID,退出状态直到其它线程对它调用了pthread_join。

pthread_join()或pthread_detach()都应该在进程中每个线程上调用,这样当线程终止时,也会释放资源。

pthread_self用于返回当前线程的ID

pthread_equal用于比较两个线程ID是否一样,如果不同返回0

pthread_exit用于终止线程,可以指定返回值,以便其他线程通过pthread_join函数获取该线程的返回值。

pthread_cancel用于通过其他线程调用来结束线程。

在对临界资源进行操作之前需要pthread_mutex_lock先加锁,操作完之后pthread_mutex_unlock再解锁。
而且在这之前需要声明一个pthread_mutex_t类型的变量,用作前面两个函数的参数。

int withdraw(struct account *account, int amount)
{
    pthread_mutex_lock(&account->mutex);
    const int balance = account->balance;
    if(balance < amount)
    {
        pthread_mutex_unlock(&account->mutex);
        return -1;
    }

    account->balance = balance - amount;
    pthread_mutex_unlock(&account->mutex);

    disburse_money(amount);

    return 0;
}

在这个例子中,不是使用全局锁,而是在结构体中定义一把锁,这样每个account实例都有自己的锁,实现对数据的加锁。

条件变量是一种同步机制,允许线程挂起,直到共享数据上的某些条件得到满足。
条件变量上的基本操作有:触发条件(当条件变为 true 时);等待条件,挂起线程直到其他线程触发条件。
条件变量要和互斥量相联结,以避免出现条件竞争–一个线程预备等待一个条件变量,
当它在真正进入等待之前,另一个线程恰好触发了该条件。

References