POSIX线程

概括

进程fork调用的问题:

  1. fork昂贵。需要把父进程内存映像复制到子进程,并在子进程中复制所有描述符,等等。虽然有写时复制(copy-on-write)技术,用以避免在子进程切实需要自己的副本之前把父进程的数据空间复制到子进程。然而即使有这样的优化措施,fork仍是昂贵的。
  2. fork返回之后,父子进程之间的消息的传递需要进程间通信(IPC)机制。调用fork之前父进程向尚未存在的子进程传递消息相当容易,因为子进程将从父进程数据空间及所有描述符的一个副本开始运行。然而子进程向父进程返回消息却比较费力

线程有时称为轻权进程(lightweight process)线程创建可能比进程快10~100倍

同一进程的所有线程共享:

  • 进程指令
  • 大多数数据
  • 打开的描述符
  • 信号处理函数和信号处置
  • 当前工作目录
  • 用户ID和组ID

每个线程各自有:

  • 线程ID
  • 寄存器集合,包括程序计数器和栈指针
  • 栈(用于存放局部变量和返回地址)
  • errno
  • 信号掩码
  • 优先级

Pthread 编程

POSIX线程,也称为Pthread,POSIX.1c标准的一部分

Pthread函数都以 pthread_ 打头

函数的错误指示

与套接字函数及大多数系统调用出错时返回-1并置errno为某个正值的做法不同,Pthread函数出错时作为函数返回值返回正值错误指示。Pthread函数不设置errno,成功为0出错为非0,因为<sys/errno.h>头文件中所有Exxxx都是正值。

线程创建及终止

#include <pthread.h>

// 创建线程,类似于进程的fork
int pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*func)(void *), void *arg);

// 等待一个给定线程终止,类似于进程的waitpid
int pthread_join(pthread_t *tid, void **status);

// 获取自身的进程ID,类似于进程的getpid
pthread_t pthread_self(void);

// 把制定线程转变为脱离状态(detached)
// 本函数通常由想让自己脱离的线程调用:pthread_detach(pthread_self)
int pthread_detach(pthread_t tid);

// 线程终止
void pthread_exit(void *status);
  1. Pthread没有办法等待任意一个线程,而指定进程ID参数为-1调用waitpid可以等待任意一个进程。

    Solaris线程函数thr_join可以等待任意一个线程结束(头文件<thread.h>)。

  2. 一个线程或者是可汇合的(joinable),或者是脱离的(detached)。

    • 当一个可汇合的线程终止时,它的线程ID和退出状态将留存到另一个线程对它调用pthread_join。
    • 脱离的线程像守护进程,当它们终止时,所有相关的资源都被释放,我们不能等待它们终止。

    因此,如果一个线程需要知道另一个线程什么时候终止,那就最好保持另一个线程的joinable状态。

  3. 线程的三种终止:

    1. pthread_exit。注意,指针status不应指向局部于调用线程的对象,因为线程终止时,这样的对象将也会消失。
    2. 启动线程的函数返回。既然该函数的必须声明成返回一个void指针,它的返回值就是相应线程的终止状态。
    3. 如果进程的main函数返回或任何线程调用了exit,整个进程终止,它的任何线程也会终止。

线程特定数据

把一个未线程化的程序转换成使用线程的版本时,有时会碰到因其中有函数使用静态变量而引起的一个常见编程错误。和许多与线程相关的其他编程错误一样,这个错误造成的故障也是不确定的

在无需考虑重入的环境下编写使用静态变量的函数无可非议,然而当同一进程内的不同线程(信号处理函数也视为线程)几乎同时调用这样的函数时就可能会发生问题,因为这些函数的静态变量无法为不同的线程保存各自的值

解决方法:

  1. 使用线程特定函数。

    • 缺点:这个方法不简单,并且转换成了只能在支持线程的系统上工作的方法。
    • 优点:调用顺序不需改动,所有变动都体现在库函数而非调用这些函数的应用程序中。
  2. 改变调用顺序,将函数调用参数和所用的静态变量存入一个结构中,例如:

    typedef struct (
    	int read_fd;
        char *read_ptr;
        size_t read_maxlen;
        int rl_cnt;
        char *rl_bufptr;
        char rl_buf[MAXLINE];
    ) Rline;
    void readline_rinit(int, void *, size_t, Rline *);
    ssize_t readline_r(Rline *);
    
    • 缺点:改动大,调用函数的所有应用程序都需要修改。
    • 优点:在支持线程和不支持线程的系统都可以使用
  3. 改变接口结构,避免使用静态变量。

    • 缺点:改动较大,可能使性能降低

使用线程特定数据是使得现有函数变为线程安全的一个常用技巧。

进程Key结构和线程Pthread结构

每个系统支持有限数量的线程特定数据元素。POSIX要求这个限制不小于128(每个进程)。系统为每个进程维护一个称为Key结构的结构数组,每两格为一个数组元素(标志和析构函数指针),如图1:

image-20190114180441114

Key结构中的标志指示这个数组元素是否正在使用,所有标志初始化为“不在使用”。
除了进程范围的Key结构数组外,系统还在进程内维护关于每个线程的多条信息。这些特定于线程的信息称为Pthread结构,如图2:

image-20190114181225601

pkey数组的所有元素都被初始化为空指针。这些128个指针是和进程内的128个可能的“键”逐一关联的值。
当调用pthread_key_create创建一个键时,系统搜索其Key结构数组找到第一个不在使用的元素,该元素的索引(0~127)称为键key,返回给调用线程的正是这个索引,每个线程可以随后为该键存储一个值(指针),而这个指针通常又是每个线程通过调用malloc获得的。
Pthread结构是系统(可能是线程函数库)维护的,而我们malloc的真正线程特定数据是由分配其的函数维护的

相关函数

#include <pthread.h>

// 初始化线程特定数据元素所用的键,若初始化函数已被调用,它就不再调用
int pthread_once(pthread_once_t *onceptr, void (*init)(void));

// 创建某个线程特定数据元素
int pthread_key_create(pthread_key_t *keyptr, void (*destructor)(void *value));

// 获取当前线程Pthread中某个键关联的值
void *pthread_getspecific(pthread_key_t key);

// 存放当前线程Pthread中某个键关联的值
int pthread_setspecific(pthread_key_t key, const void *value);

每当一个使用线程特定数据的函数被调用时,pthread_once通常转而被改函数调用。pthread_once_t参数只想的变量中的值确保init参数所指的函数在进程范围内只被调用一次。

举个🌰:在readline中使用线程特定数据

  1. 一个线程被启动,多个线程被创建。

  2. 其中一个线程事都个调用readline函数的线程,该线程转而调用pthread_key_create。系统在进程Key中找到第一个未用的元素,并返回索引(本例中为1)

    使用pthread_once函数确保pthread_key_create只被第一个调用readline的线程调用

  3. readline调用pthread_getspecific获取本线程的pkey[1]的值,返回值是一个空指针。readline于是调用malloc分配内存区,用于为本线程跨相继的readline调用保存特定于线程的信息。readline按照需要初始化内存区,并调用pthread_setspecific把对应所创建键的线程特定数据指针pkey[1]设置为指向它刚刚分配的内存区,如图3:

    image-20190114182752229

  4. 另一个线程n调用readline,当时也许线程0仍然在readline内执行。readline调用pthread_once试图初始化它的线程特定数据元素所用的键,然而初始化已被调用过,就不再调用了。

  5. 线程n中,readline调用pthread_getspecific获取本线程的pkey[1]值,返回一个空指针。线程n于是像线程0一样,先调用malloc再调用pthread_setspecific,如图4:

    image-20190114210453371

  6. 线程n继续在readline中执行,使用和修改它的线程特定数据

  7. ...

  8. 当线程终止时,这个线程扫描该线程的pkey数组,为每个非空的pkey指针调用相应的析构函数(pthread_key_create中指定,存放在图1进程Key结构中)

static pthread_key_t rl_key;		// 一个进程内只有一个(pthread_key_create只调用一次)
									// 所以,虽然是静态变量,但是是线程安全的
static pthread_once_t rl_once = PTHREAD_ONCE_INIT;

typedef struct {
    int rl_cnt;
    char *rl_bufptr;
    char rl_buf[MAXLINE];
}Rline;

static void readline_destructor(void *ptr)
{
    free(ptr);
}

static void readline_once(void)
{
    pthread_key_create(&rl_key, readline_destructor);
}

static ssize_t my_read(Rline *tsd, int fd, char *ptr)
{
    if (tsd->rl_cnt <= 0)
    {
        again:
        	if ((tsd->rl_cnt = read(fd, tsd->rl_buf, MAXLINE)) < 0)
            {
                if (errno == EINTR)
                    goto again;
                return -1;
            }else if (tsd->rl_cnt == 0)
                return 0;
        	tsd->rl_bufptr = tsd->rl_buf;
    }
    tsd->rl_cnt--;
    *ptr = *tsd->rl_bufptr;
    return 1;
}

static ssize_t readline(int fd, void *vptr, size_t maxlen)
{
    size_t n, rc;
    char c, *ptr;
    Rline *tsd;
    
    pthread_once(&rl_once, readline_once);
    if((tsd = pthread_getspecific(rl_key)) == nullptr)
    {
        tsd = calloc(1, sizeof(Rline));
        pthread_setspecific(rl_key, tsd);
    }
    ptr = vptr;
    for (n = 1; n < maxlen; n++)
    {
        if ((rc = my_read(tsd, fd, &c)) == 1)
        {
            *ptr++ = c;
            if (c == '\n')
                break;
        } 
        else if (rc == 0)
        {
            *ptr = 0;
            return n-1;
        }
        else
            return -1;
    }
    *ptr = 0;
    return n;
}

互斥锁

用于防止同时访问某个共享变量

举个🌰:对共享变量的并发编程

线程编程也称为并发编程。并发编程时,多个线程并发地运行且访问相同的变量时,可能引发错误。

举个🌰:

两个线程中都有递减某个全局变量的操作。如果一个线程在递减变量的中途被挂起,另一个线程执行并递减同一个变量。假设C编译器将递减运算转换为3个机器指令:从内存装载到寄存器、递减寄存器、从寄存器存储到内存。则可能出现一下情况;

  1. 线程A运行,把变量n的值3装载到一个寄存器
  2. 系统把运行线程从A切换到B。A的寄存器被保存,B的寄存器被恢复
  3. 线程B执行递减,新值2存储到n
  4. 一段时间后,系统把运行线程从B切换回A。A的寄存器恢复,A从原来离开的地方继续执行(此时A寄存器中装载的n为3),寄存器递减,再将2存储到n。

最终结果n为2,与预期的1不符。

相关函数

一个解决并发编程共享变量时可能出现的错误的方法:访问该变量的前提条件是持有该互斥锁。

#include <pthread.h>

// 互斥锁上锁
int pthread_mutex_lock(pthread_mutex_t *mptr);

// 互斥锁解锁
int pthread_mutex_unlock(pthread_mutex_t *mptr);
  1. 互斥锁类型为pthread_mutex_t。

  2. 如果试图上锁已被另外某个线程锁住的互斥锁,本线程将被阻塞,知道该互斥锁被解锁为止。

  3. 如果某个互斥锁变量是静态分配的,必须把它初始化为常值PTHREAD_MUTEX_INITIALIZER。

    如果在共享内存区分配一个互斥锁,那么必须通过调用pthread_mutex_init函数在运行时把它初始化。

条件变量

在等待某个条件发生期间能让线程进入睡眠

举个🌰:用pthread_join等待任意线程终止

可以用一个全局变量记录已终止的线程,并用一个互斥锁保护它。主函数需要一次又一次地锁住这个互斥锁来检查是否有任何进程终止了,这种方法称为轮询,这意味着主循环永远不进入睡眠,非常浪费CPU时间。

我们需要一个让主函数进入睡眠,直到某个线程通知它有事可做才醒来的方法。条件变量结合互斥锁能够提供这个功能,互斥锁提供互斥机制,条件变量提供信号机制

相关函数

#include <pthread.h>

// 等待信号发送到某个条件变量
int pthread_cond_wait(pthread_cond_t *cptr, pthread_mutex_t *mptr);
// 允许线程设置一个阻塞时间的限制,abstime指定函数必须返回时刻的系统时间;超时返回ETIME错误
int pthread_cond_timedwait(pthread_cond_t *cptr, pthread_mutex_t *mptr, const struct timespec *abstime);

// 唤醒等待在相应条件变量上的单个线程
int pthread_cond_signal(pthread_cond_t *cptr);
// 唤醒等待在相应条件变量上的所有线程
int pthread_cond_broadcast(pthread_cond_t *cptr);
  1. 如果某个条件变量是静态分配的,必须把它初始化为常值PTHREAD_COND_INITIALIZER。
  2. 第二个函数中的signal并不指称Unix的SIGxxx信号。
  3. 调用第一个函数时,线程投入睡眠并释放线程持有的指定互斥锁,等待某个线程用第三个函数发送信号到与之关联的条件变量。当调用线程从此函数中返回时,该线程又再次持有该互斥锁。
posted @ 2019-01-15 13:25  一棵球和一枝猪  阅读(367)  评论(0编辑  收藏  举报