Linux系统编程之线程

本章节介绍Linux下多线程编程方面的知识,涉及到线程和进程的关系,优缺点,线程基础背景,线程原语以及线程属性等方面内容,后续还会进阶的介绍多线程而引申出来的线程同步问题。让我们更加清晰的了解到多线程编程带来的魅力。

线程

线程与进程

典型的UNIX/Linux进程可以看成只有一个控制线程:一个进程在同一时刻只做一件事情。有了多个控制线程后,在程序设计时可以把进程设计成在同一时刻做不止一件事,每个线程各自处理独立的任务。  

进程是程序执行时的一个实例,是担当分配系统资源(CPU时间、内存等)的基本单位。在面向线程设计的系统中,进程本身不是基本运行单位,而是线程的容器。程序本身只是指令、数据及其组织形式的描述,进程才是程序(那些指令和数据)的真正运行实例。

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。线程包含了表示进程内执行环境必须的信息,其中包括进程中表示线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno常量以及线程私有数据。进程的所有信息对该进程的所有线程都是共享的,包括可执行的程序文本、程序的全局内存和堆内存、栈以及文件描述符。在Unix和类Unix操作系统中线程也被称为轻量级进程
(lightweight processes),但轻量级进程更多指的是内核线程(kernel thread),而把用户线程(user thread)称为线程。

进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

线程间共享资源

线程几乎共享进程中的任何资源,因为都是同属一个容器内。具体罗列如下:

  1. 文件描述符表
  2. 每种信号的处理方式
  3. 当前工作目录
  4. 用户ID和组ID
  5. 内存地址空间
  6. 代码段中的 Text data bss 堆 共享库
线程间非共享资源

但是线程间也有不共享的资源,如:

  1. 线程id
  2. 处理器现场和栈指针(内核栈)
  3. 独立的栈空间(用户空间栈)
  4. errno变量
  5. 信号屏蔽字
  6. 调度优先级
线程与进程有缺点

使用多线程的理由之一是和进程相比,它是一种非常”节俭”的多任务操作方式。我们知道,在Linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种”昂贵”的多任务工作方式。而运行于一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。据统计,总的说来,一个进程的开销大约是一个线程开销的30倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。

使用多线程的理由之二是线程间方便的通信机制。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其它线程所用,这不仅快捷,而且方便。当然,数据的共享也带来其他一些问题,有的变量不能同时被两个线程所修改,有的子程序中声明为static的数据更有可能给多线程程序带来灾难性的打击,这些正是编写多线程程序时最需要注意的地方。

优点:

提高程序的并发性,提高应用程序响应
开销小,不用重新分配内存
通信、共享数据方便
使多CPU系统更加有效,操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上
改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分

缺点:

线程不如进程来的稳定(库实现)
多进程下调试困难(gdb支持不好)
无法使用unix经典事件,信号(线程下信号处理共享)
线程库安装

Linux下的线程实现基于库的方式,因此使用编译过程中都需要额外指定编译库 -lpthread

  1. 查看 pthread 函数 Manpage

    man -k pthread

  2. 安装 pthread 相关的 Manpage

    sudo apt-get install manpages-posix manpages-posix-dev

线程原语

一下介绍多线程编程中涉及的线程原语。

pthread_create
#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg)

pthread_t *thread: 传递一个pthread_t变量地址,用于保存新线程的tid
pthread_attr_t *attr: 线程属性设置,默认为 NULL
void *(*start_routine)(void *): 函数指针,新线程加载后执行的函数模块
void *arg:  加载函数调用的参数

返回值: 成功返回0,失败返回错误号;
(注意其他函数失败返回 -1, 设置errno,这里的pthread时库函数实现,返回值返回错误号,其线程中存在errno是为了兼容其他函接口而提供,pthread并不使用他)

编译时,添加控制项 -lpthread

在一个线程中调用pthread_create()创建新的线程后,当前线程从pthread_create() 返回继续往下执行,而新的线程所执行的代码由我们传给pthread_create的函数指针 start_routine 决定。start_routine 函数接收一个参数,是通过 pthread_create 的 arg 参数传递给它的,该参数的类型为void ,这个指针按什么类型解释由调用者自己定义。start_routine的返回值类型也是void ,这个指针的含义同样由调用者自己定义。start_routine 返回时,这个线程就退出了,其它线程可以调用pthread_join得到start_routine的返回值,类似于父进程调用wait(2)得到子进程的退出状态。

pthread_create成功返回后,新创建的线程的id被填写到thread参数所指向的内存单元。我们知道进程id的类型是pid_t,每个进程的id在整个系统中是唯一的,调用getpid(2) 可以获得当前进程的id,是一个正整数值。线程id的类型是thread_t,它只在当前进程中保证是唯一的,在不同的系统中thread_t这个类型有不同的实现,它可能是一个整数值, 也可能是一个结构体,也可能是一个地址,所以不能简单地当成整数用printf打印,调用 pthread_self(3)可以获得当前线程的id。

代码实例:
测试进程可以创建多少个线程

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void *pthread_fun(void *arg){
    while(1){
        sleep(1);
    }
}

int main(void){

    pthread_t tid;
    int i =1,err;
    while(1){
        err = pthread_create(&tid, NULL,pthread_fun,NULL);
        if(err != 0){
            printf("%s \n", strerror(err));
            exit(1);
        }
        printf("%x   %d\n",tid,i++);
    }
    return 0;
}
pthread_self

获取调用线程tid

pthread_t pthread_self(void)

实例测试:

#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>

// 线程家在函数,必须规定:返回值 void *, 参数 void *
void* pthread_fun(void* arg){
    printf("pthread pid: %d\n", getpid());
    printf("pthread id: %x\n", (unsigned int)pthread_self());
    printf("pthread arg: %d\n",(int *)arg);
}

int main(void){

    pthread_t ptid;
    int n = 10;
    int err;
    if((err = pthread_create(&ptid, NULL, pthread_fun, (void *)n))!=0){
        fprintf(stderr,"can not create thread: %s\n",strerror(err));
        exit(1);
    }

    printf("main return ptid: %x\n", (unsigned int)ptid);
    printf("main pid: %d\n", getpid());
    printf("main thread id: %x\n", pthread_self());

    // 等待1s,子线程处理
    sleep(1);

}

由于pthread_create 的错误码不保存在 errno 中,因此不能直接用 perror(3) 打印错误信 息,可以先用 strerror(3) 把错误码转换成错误信息再打印。

如果任意一个线程调用了 exit 或 _exit ,则整个进程的所有线程都终止,由于从 main 函数 return 也相当于调用 exit,为了防止新创建的线程还没有得到执行就终止,我们在main函数return之前延时1秒,这只是一种权宜之计,即使主线程等待1秒,内核也不一定会调度新创建的线程执行。

pthread_exit

调用线程退出函数,注意和 exit 函数的区别,任何线程里 exit 导致进程退出,其他线程未工作结束,主控线程退出时不能 return 或 exit 。

void pthread_exit(void *value_ptr)

void *value_ptr: 线程退出时传出的参数,可以指值或者地址,地址不可以是线程内部申请的局部地址

需要注意,pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_join

将线程挂起,等待处理结束。

int pthread_join(pthread_t thread, void **value_ptr)

pthread_t thread: 需要挂起监测的线程
void **value_ptr: 存放线程中止状态值

调用该函数的线程将挂起等待,直到 id 为 thread 的线程终止。thread 线程以不同的方法终止,通过 pthread_join 得到的终止状态是不同的,总结如下:

  1. 如果 thread 线程通过 return 返回, retval 所指向的单元里存放的是 thread 线程函数的返回值。
  2. 如果 thread 线程被别的线程调用 pthread_cancel 异常终止掉,retval 所指向的单元里存放的是常数 PTHREAD_CANCELED。
  3. 如果 thread 线程是自己调用 pthread_exit 终止的,retval 所指向的单元存放的是传给 pthread_exit 的参数。
  4. 如果对 thread 线程的终止状态不感兴趣,可以传 NULL 给 retval 参数。
pthread_cancel

在进程内某个线程可以取消另一个线程。

int pthread_cancel(pthread_t thread)

pthread_t thread: 被取消的线程
退出值,定义在Linux的pthread库中,常数 PTHREAD_CANCELED 的值是 -1

代码实例:
通过 return,pthread_exit,pthread_cancel分别控制线程结束

#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>

// 正常return方式返回
void* pthread_fun1(void* arg){
    printf("thread 1 return \n");
    return (void*)1;
}

// 通过pthread_exit方式返回
void* pthread_fun2(void* arg){

    printf("thread 2 pthread_exit \n");
    pthread_exit((void*)2);
}

// 通过其他线程调用pthread_cancel返回
void* pthread_fun3(void* arg){
    while(1){
        printf("thread 3 do while - sleep\n");
        sleep(1);
    }
}    

int main(void){

    pthread_t tid;
    void* tret;

    pthread_create(&tid, NULL, pthread_fun1, NULL);
    pthread_join(tid,&tret);
    printf("thread 1 exit code with return %d\n",(int)tret);

    pthread_create(&tid, NULL, pthread_fun2, NULL);
    pthread_join(tid,&tret);
    printf("thread 2 exit code with pthread_exit %d\n",(int)tret);

    pthread_create(&tid, NULL, pthread_fun3, NULL);
    sleep(3);
    pthread_cancel(tid);
    pthread_join(tid,&tret);
    printf("thread 3 exit code with pthread_cancel %d\n",(int)tret);

    return 0;
}
pthread_detach

一般情况下,线程终止后,其终止状态一直保留到其它线程调用 pthread_join 获取它的状态为止。但是线程也可以被置为detach状态,这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。

int pthread_detach(pthread_t thread)
pthread_t thread: 需要分离态的线程id

不能对一个已经处于detach状态的线程调用 pthread_join,这样的调用将返回 EINVAL。如果已经对一个线程调用了 pthread_detach 就不能再调用 pthread_join(互斥) 了。

如果子线程的资源需要主线程来回收的话,那么主线程就一定要等子线程结束,因为子线程结束,你就不能去回收;但是如果子线程资源要自动回收的话,那么主线程就不必等了。

代码实例:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>

void *pthread_fun(void *arg){
    int i = 3;
    while(i--){
        printf("thread count %d\n", i);
        sleep(1);
    }
    return (void *)1;
}

int main(void){

    pthread_t tid;
    void *tret;
    int err;

    pthread_create(&tid, NULL, pthread_fun, NULL);

    // 分两次运行,一次打开以下代码编译运行,一次注释代码运行,查看结果
    pthread_detach(tid);

    // 已分离态,禁止调用 pthread_join
    // pthread_join(tid,NULL);

    while(1){
        err = pthread_join(tid, &tret);
        if(err != 0){
            fprintf(stderr,"thread %s \n", strerror(err));
        }else{
            fprintf(stderr, "thread exit code %d\n",(int)tret);
        }
        sleep(1);
    }
    return 0;
}

上述代码,第一次使用 pthread_detach 将线程置于分离态,此时 pthread_join 失败,走打印 strerror(err);不使用 pthread_detach,等待子线程运行结束,打印输出正常运行结束返回值。

pthread_equal

比较两个线程是否相等

int pthread_equal(pthread_t t1, pthread_t t2)

pthread_t t1: 比较线程id 1
pthread_t t2: 比较线程id 2 

线程终止方式

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程主函数 return。这种方法对主控线程不适用,从 main 函数 return 相当于调用 exit。
  2. 一个线程可以调用 pthread_cancel 终止同一进程中的另一个线程。
  3. 线程可以调用 pthread_exit 终止自己。

注意:同一个进程中,pthread_cancel 向另一个线程发送终止信号,系统并不会马上关闭被终止的线程,只有在终止线程下次系统调用时,才会真正的结束。或者调用 pthread_testcancel,让内核去检测是否需要取消当前线程。

线程属性

linux下线程的属性是可以根据实际项目需要进行设置,之前我们讨论的线程都是采用线程的默认属性,默认属性已经可以解决绝大多数开发时遇到的问题。如我们对程序的性能提出更高的要求那么需要设置线程属性,比如可以通过设置线程栈的大小来降低内存的使用,增加最大线程个数。

typedef struct
{
    int                           detachstate;     线程的分离状态
    int                          schedpolicy;   线程调度策略
    struct sched_param      schedparam;   线程的调度参数
    int                          inheritsched;    线程的继承性
    int                          scope;          线程的作用域
    size_t                      guardsize; 线程栈末尾的警戒缓冲区大小
    int                          stackaddr_set;
    void *                     stackaddr;      线程栈的位置
    size_t                      stacksize;       线程栈的大小
}pthread_attr_t;

属性值不能直接设置,须使用相关函数进行操作,初始化的函数为 pthread_attr_init。这个函数必须在 pthread_create 函数之前调用。之后须用 pthread_attr_destroy 函数来释放资源。

线程属性主要包括如下属性:作用域(scope)、栈尺寸(stack size)、栈地址 (stack address)、优先级(priority)、分离的状态(detached state)、调度策略和参数(scheduling policy and parameters)。

默认的属性为非绑定、非分离、缺省M的堆栈、与父进程同样级别的优先级。

线程属性初始化

先初始化线程属性,再 pthread_create 创建线程,最后 pthread_attr_destroy 回收属性。

int pthread_attr_init(pthread_attr_t *attr)

int pthread_attr_destroy(pthread_attr_t *attr)

pthread_attr_t *attr: 线程属性结构体地址
线程的分离状态(detached state)

线程的分离状态决定一个线程以什么样的方式来终止自己。

非分离状态: 线程的默认属性是非分离状态,这种情况下,原有的线程等待创建的线程结束。只有当 pthread_join 函数返回时,创建的线程才算终止,才能释放自己占用的系统资源。

分离状态: 分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。应该根据自己的需要,选择适当的分离状态。

int pthread_attr_setdetachstate(pthread_attr_t attr, int detachstate)
int pthread_attr_getdetachstate(const pthread_attr_t
attr, int *detachstate)

pthread_attr_t *attr: 线程属性结构体地址
int detachstate: 分离 PTHREAD_CREATE_DETACHED,非分离状态 THREAD_CREATE_JOINABLE

这里要注意的一点是,如果设置一个线程为分离线程,而这个线程运行又非常快,它很可能在 pthread_create 函数返回之前就终止了,它终止以后就可能将线程号和系统资源移交给其他的线程使用,这样调用 pthread_create 的线程就得到了错误的线程号。要避免这种情况可以采取一定的同步措施,最简单的方法之一是可以在被创建的线程里调用 pthread_cond_timedwait 函数,让这个线程等待一会儿,留出足够的时间让函数 pthread_create 返回。设置一段等待时间,是在多线程编程里常用的方法。但是注意不要使用诸如wait 之类的函数,它们是使整个进程睡眠,并不能解决线程同步的问题。

代码实例:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>

void *pthread_fun(void *arg){
    int i = 30;
    while(i--){
        printf("%x   %d\n",pthread_self(),i);
        sleep(1);
    }
    return (void *)1;
}

int main(void){

    pthread_t tid;
    pthread_attr_t attr; // attr里面保存的是垃圾值
    int err;

    pthread_attr_init(&attr); // 设置线程属性

    // int detachstate: 
    // PTHREAD_CREATE_DETACHED, PTHREAD_CREATE_JOINABLE
    // 设置线程为分离状态
    pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED);

    pthread_create(&tid, &attr,pthread_fun,NULL);
    err = pthread_join(tid,NULL);

    while(1){
        if(err != 0){
            printf("%s \n", strerror(err));
            sleep(15);
            pthread_exit((void *)1);
        }
    }
    return 0;

}
线程的栈地址(stack address)

POSIX.1定义了两个常量 _POSIX_THREAD_ATTR_STACKADDR 和 _POSIX_THREAD_ATTR_STACKSIZE 检测系统是否支持栈属性。也可以给sysconf函数传递 _SC_THREAD_ATTR_STACKADDR或 _SC_THREAD_ATTR_STACKSIZE 来进行检测。

当进程栈地址空间不够用时,指定新建线程使用由 malloc 分配的空间作为自己的栈空间。通过 pthread_attr_setstackaddr 和 pthread_attr_getstackaddr 两个函数分别设置和获取线程的栈地址。传给 pthread_attr_setstackaddr 函数的地址是缓冲区的低地址(不一定是栈的开始地址,栈可能从高地址往低地址增长)。

int pthread_attr_getstackaddr(const pthread_attr_t *attr, void **stackaddr)

int pthread_attr_setstackaddr(pthread_attr_t *attr, void *stackaddr)

pthread_attr_t *attr: 线程属性结构体
*stackaddr: 获取的栈地址
成功返回0,错误返回错误号

此函数已过时,使用 pthread_attr_getstacksize 替换

线程的栈大小(stack size)

当系统中有很多线程时,可能需要减小每个线程栈的默认大小,防止进程的地址空间不够用,当线程调用的函数会分配很大的局部变量或者函数调用层次很深时,可能需要增大线程栈的默认大小。

函数 pthread_attr_getstacksize 和 pthread_attr_setstacksize 提供设置。

int pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize)

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize)

pthread_attr_t *attr: 指向一个线程属性的指针
size_t *stacksize: 返回线程的堆栈大小
返回值: 成功返回0,错误返回错误号

除上述对栈设置的函数外,还有以下两个函数可以获取和设置线程栈属性

int pthread_attr_getstack(const pthread_attr_t *attr, void **stackaddr, size_t *stacksize)

int pthread_attr_setstacksize(pthread_attr_t *attr, void *stackaddr, size_t stacksize)

pthread_attr_t *attr: 指向一个线程属性的指针
void *stackaddr: 返回获取的栈地址
size_t *stacksize: 返回获取的栈大小
返回值: 成功返回0,错误返回错误号
代码实例

使用线程属性,指定线程栈大小,测试系统可以创建线程数量。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>

// 指定栈大小
#define SIZE 0x1000

int print_ntimes(char *str){
    sleep(1);
    printf("%s\n",str);
    return 0;
}

// 线程载入实体函数
void* pthread_fun(void *arg){
    int n = 3;
    while(n--){
        print_ntimes("hello allies \n");
    }
}

int main(void){

    pthread_t tid;
    int err, detachstate, i =1;
    // 线程属性结构体
    pthread_attr_t attr;
    // 栈大小
    size_t stacksize;
    // 栈地址
    void *stackaddr;
    // 初始化线程属性
    pthread_attr_init(&attr);
    // 获取并打印默认的栈信息
    pthread_attr_getstack(&attr, &stackaddr,&stacksize);
    printf("stackaddr = %p\n",stackaddr );
    printf("stacksize = %x\n", (int)stacksize);

    // 获取分离状态
    pthread_attr_getdetachstate(&attr, &detachstate);
    if(detachstate == PTHREAD_CREATE_DETACHED)
        printf("thread detached \n");
    else if(detachstate == PTHREAD_CREATE_JOINABLE)
        printf("thread join\n");
    else
        printf("thread uknown\n");

    // 设置分离态属性
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    while(1){

        // 手动分配栈内存
        stackaddr = malloc(SIZE);
        if(stackaddr == NULL){
            perror("malloc error!");
            exit(1);
        }
        stacksize = SIZE;
        // 设置栈属性
        pthread_attr_setstack(&attr,stackaddr,stacksize);
        // 线程属性设置完成,在创建线程,此时传入属性参数 attr
        err = pthread_create(&tid, &attr, pthread_fun, NULL);
        if(err != 0){
            printf("%s\n",strerror(err));
            exit(1);
        }
        printf("%d\n",i++);
    }
    // 回收线程属性
    pthread_attr_destory(&attr);
    return 0;
}

输出打印:

Allies:xiancheng rememberme$ ./pthread_attr_stack
stackaddr = 0x0
stacksize = 80000
thread join
1
2
3
...
4094
4095
Resource temporarily unavailable

若干注意

多线程虽然给我提供很多便利,但是还是要注意一下几点:

  1. 主线程退出其他线程不退出,主线程应调用ptrhed_exit
  2. 避免僵线程,使用join,分离态等手动回收或者自动释放资源
  3. malloc 和 mmap 申请的内存可以被其他线程释放
  4. 如果线程终止时没有释放加锁的互斥量,则该互斥量不能再被使用
  5. 应避免在多线程模型中调用 fork,除非马上 exec,子进程中只有调用 fork 的线程存在,其他线程在子进程中均 pthread_exit
  6. 信号的复杂语义很难和多线程共存,应避免在多线程引入信号机制

总结

本博客主要集中的介绍了 Linux 下多线程编程的内容,设计线程进程之间的比较,线程创建销毁原语以及更高级的线程属性原语。对于线程原语也仅仅是停留在理论入门阶段,更高级的方法需要在以后实际工作中切实落实。最后,毕竟 Linux 下的多线程是基于库实现的,使用时需要自己主机安装响应的线程库,之后在编译过程中添加 -lpthread 条件。

对于多线程编程带来的资源抢夺问题,而产生的同步问题,再下一篇博客中集中说明 Linux 下各种同步锁机制,让我们拭目以待。

更多参考

邢文鹏Linux教学资料

坚持原创技术分享,您的支持将鼓励我继续创作!