Linux多线程编程入门
2021-01-31 22:51:08 阿炯

线程基础

通常线程指的是共享相同地址空间的多个任务。线程最大的特点就是在同一个进程中创建的线程共享该进程的地址空间;但一个线程仍用task_struct 来描述,线程和进程都参与统一的调度。所以多线程的好处便体现出来:
1大大提高了任务切换的效率;因为各线程共享进程的地址空间,任务切换时只要切换task_struct 即可;
2线程间通信比较方便;因为在同一块地址空间,数据共享;

当然,共享地址空间也会成为线程的缺点,因为共享地址空间,如果其中一个线程出现错误比如段错误,整个线程组都会崩掉!Linux之所以称呼其线程为LWP( Light Weight Process ),因为从内核实现的角度来说,它并没有为线程单独创建一个结构,而是继承了很多进程的设计:
1)继承了进程的结构体定义task_struct;
2)没有专门定义线程ID,复用了PID;
3)更没有为线程定义特别的调度算法,而是沿用了原来对task_struct 的调度算法。

在最新的Linux内核里线程已经替代原来的进程称为调度的实际最小单位。原来的进程概念可以看成是多个线程的容器,称之为线程组;即一个进程就是所有相关的线程构成的一个线程组。传统的进程等价于单线程进程。每个线程组都有自己的标识符 tgid (数据类型为 pid_t ),其值等于该进程(线程组)中的第一个线程(group_leader)的PID。


1、线程与多线程的定义

线程存在于进程当中,是操作系统调度执行的最小单位。说通俗点线程就是干活,多线程也就是同时可以干不同的活而且还不会互相打扰,线程并没有自己的独立空间。

2、进程与线程的区别与联系

如果说进程是一个资源管家,负责从主人那里要资源的话,那么线程就是干活的苦力。一个管家必须完成一项工作,就需要最少一个苦力,也就是说,一个进程最少包含一个线程,也可以包含多个线程。苦力要干活,就需要依托于管家,所以说一个线程,必须属于某一个进程。进程有自己的地址空间,线程使用进程的地址空间,也就是说,进程里的资源,线程都是有权访问的,比如说堆啊,栈啊,静态存储区什么的。

线程就是个无产阶级,但无产阶级干活,总得有自己的劳动工具吧,这个劳动工具就是栈,线程有自己的栈,这个栈仍然是使用进程的地址空间,只是这块空间被线程标记为了栈。每个线程都会有自己私有的栈,这个栈是不可以被其他线程所访问的。

从上面我们知道了进程和线程区别,使用多线程首先是要和进程相对比,它是一种非常便捷的多任务操作方式;我们知道,在Linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种"昂贵"的多任务工作方式。而运行于一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。总的说来,一个进程的开销大约是一个线程开销的30倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其它线程所用,这不仅快捷,而且方便。当然,数据的共享也带来其他一些问题,有的变量不能同时被两个线程所修改,有的子程序中声明为static的数据更有可能给多线程程序带来灾难性的打击,这些正是编写多线程程序时最需要注意的地方。

线程操作相关的函数:

2.1、线程创建函数

int pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*func) (void *), void *arg);

参数说明:
pthread_create用于创建一个线程,成功返回0,否则返回Exxx为正数。

pthread_t tid:线程id的类型为pthread_t,通常为无符号整型,当调用pthread_create成功时,通过tid指针返回。

const pthread_attr_t *attr:指定创建线程的属性,如线程优先级、初始栈大小、是否为守护进程等。可以使用NULL来使用默认值,通常情况下我们都是使用默认值。

void *(*func) (void *):函数指针func,指定当新的线程创建之后,将执行的函数。

void *arg:线程将执行的函数的参数。如果想传递多个参数,请将它们封装在一个结构体中。

2.2、线程等待的函数

int pthread_join (pthread_t tid, void ** status);

参数说明:
pthread_join用于等待某个线程退出,成功返回0,否则返回Exxx为正数。
pthread_t tid:指定要等待的线程ID。
void ** status:如果不为NULL,那么线程的返回值存储在status指向的空间中这就是为什么status是二级指针的原因!这种参数也称为“值-结果”参数。

2.3、获得线程自身的ID的函数

pthread_t pthread_self (void);

pthread_self用于返回当前线程的ID。

2.4、线程分离的函数

int pthread_detach (pthread_t tid);

pthread_detach用于指定线程变为分离状态,就像进程脱离终端而变为后台进程类似。成功返回0,否则返回Exxx为正数。变为分离状态的线程,如果线程退出,它的所有资源将全部释放。而如果不是分离状态,线程必须保留它的线程ID,退出状态直到其它线程对它调用了pthread_join。

2.5、退出线程(终止线程)的函数

void pthread_exit (void *status);

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

参数说明:
void *status:指针线程终止的返回值。

Linux内核只提供了轻量进程的支持,限制了更高效的线程模型的实现,但Linux着重优化了进程的调度开销,一定程度上弥补了这一缺陷。目前最为流行的线程机制LinuxThreads所采用的就是线程-进程“一对一”模型,调度交给核心,而在用户级实现一个包括信号处理在内的线程管理机制。

线程编程实例:pthread.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

void *thread_1(void *arg){
   printf("thread one\n");
}
void *thread_2(void *arg){
   printf("thread two\n");
}

int main(){
    int ret = 0;
    pthread_t pid = 0;
    pthread_t pid1 = 0;
    //创建线程1
    ret = pthread_create(&pid, NULL, thread_1, NULL);
    if(ret < 0){
      perror("pthread_create");
      exit(EXIT_FAILURE);
    }
    //创建线程2
    ret = pthread_create(&pid1, NULL, thread_2, NULL);
    if(ret < 0){
      perror("pthread_create");
      exit(EXIT_FAILURE);
    }
    //等待线程退出
    pthread_join(pid, NULL);
    pthread_join(pid1, NULL);
    return 0;
}

注意,在gcc中,默认是不包含线程相关的库的,所以在编译这个程序操作如下是会产生错误的,如下图所示:


正确的编译方式是下面这样,要加上-lpthread这个库,确保编译的时候链接上。如下图所示:


运行结果:


pthread.c创建了2个线程,并在线程中实现打印功能,最终调用pthread_join等待子线程运行结束,一并退出。

通过上面的一个例程会发现一个问题,当我们去操作一个文件的时候会出现一个问题,就是不知道该听谁的,这时候我们就需要一个互斥锁,让线程一个个来,a执行完之后在执行b。

pthread2.c

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

//定义一个互斥锁变量
pthread_mutex_t m;
void *thread_1(void *arg){
    //互斥锁加锁
    pthread_mutex_lock(&m);
    printf("thread one\n");
    //互斥锁解锁
    pthread_mutex_unlock(&m);
}
void *thread_2(void *arg){
    pthread_mutex_lock(&m);
    printf("thread two\n");
    pthread_mutex_unlock(&m);
}

int main(){
    int ret = 0;
    //以动态方式创建互斥锁
    ret = pthread_mutex_init(&m, NULL);
    if(ret < 0){
        perror("pthread_mutex_init");
        exit(EXIT_FAILURE);
    }
    pthread_t pid = 0;
    pthread_t pid1 = 0;
    ret = pthread_create(&pid, NULL, thread_1, NULL);
    if(ret < 0){
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }
    ret = pthread_create(&pid1, NULL, thread_2, NULL);
    if(ret < 0){
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }
    pthread_join(pid, NULL);
    pthread_join(pid1, NULL);
    return 0;
}

一样的,同样执行编译加上-lpthread参数保证编译时链接线程库,然后运行,如下图所示:


线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该函数的某个数据时,进行保护,其它线程不能进行访问直到该线程读取完成,其它线程才可以使用。不会出现数据不一致或者数据污染。


常见的线程函数

1、创建线程

pthread_create()函数描述如下:
所需头文件:#include <pthread.h>
函数原型
int pthread_create(pthread_t *thread,const pthread_attr_t *attr, void *(* routine)(void *), void *arg)
函数参数
thread:创建的线程
attr:指定线程的属性,NULL表示使用缺省属性
routine:线程执行的函数
arg:传递给线程执行的函数的参数

函数返回值
成功:0
出错:-1

1这里routine 是回调函数callback,其函数类型由内核来决定,这里我们将其地址传给内核;这个函数并不是线程创建了就会执行,而是只有当其被调度到cpu上时才会被执行;
2arg 是线程执行函数的参数,这里我们将其地址穿进去,使用时需要先进行类型转换,才能使用;如果参数不止一个,我们可以将其放入到结构体中;

2、pthread_join() 函数

其函数描述如下:
所需头文件:#include <pthread.h>
函数原型:int thread_join(pthread_t thread, void  ** value_ptr)

函数参数
thread:要等待的线程
value_ptr:指针 *value_ptr 指向线程返回的参数

函数返回值
成功:0
出错:-1

这里,我们可以看到 value_ptr 是个二级指针,其是出参,存放的是线程返回参数的地址;

3、pthread_exit 函数

其函数描述如下:
所需头文件:#include <pthread.h>
函数原型:int pthread_exit(void *value_ptr)
函数参数:value_ptr:线程退出时返回的值

函数返回值
成功:0
出错:-1

和进程中的exit() 、wait()一样,这里pthread_join 与 pthread_exit 是工作在两个线程之中;

线程通过第三方的线程库来实现,所以这里要 -lpthread ,-l 是链接一个库,这个库是pthread;

线程之间通过二级指针来传送参数的地址这是进程所不具备的,因为他们的地址空间独立,但两个线程之间的通信,传递的数据的生命周期必须是静态的。可以使全局变量、static修饰的数据、堆里面的数据;这个程序中的message就是一个全局变量。其中一个线程可以修改它,另一个线程得到它修改过后的message。


线程的同步和互斥

先来了解同步和互斥的基本概念:
临界资源:某些资源来说,其在同一时间只能被一段机器指令序列所占用。这些一次只能被一段指令序列所占用的资源就是所谓的临界资源。
临界区:对于临界资源的访问,必须是互斥进行。也就是当临界资源被一个指令序列占用时,另一个需要访问相同临界资源的指令序列就不能被执行。指令序列不能执行的实际意思就是其所在的进程/线程会被阻塞。所以我们定义程序内访问临界资源的代码序列被称为临界区。
互斥:是指同事只允许一个访问者对临界资源进行访问,具有唯一性和排它性。但互斥无法限制访问这个对资源的访问顺序,即访问时无序的。
同步:是指在互斥的基础上,通过其他机制实现访问者对资源的有序访问。

1、线程间互斥

引入互斥(mutual   exlusion)锁的目的是用来保证共享数据的完整性。

互斥锁主要用来保护临界资源。每个临界资源都有一个互斥锁来保护,任何时刻最多只能有一个线程能访问该资源;线程必须先获得互斥锁才能访问临界资源,访问完资源后释放该锁。如果无法获得锁,线程会阻塞直到获得锁为止;通常,我们在临界区前上锁,临界区后解锁;

1初始化互斥锁函数
所需头文件:#include <pthread.h>
函数原型
int pthread_mutex_init (pthread_mutex_t  *mutex,  pthread_mutexattr_t  *attr )//初始化互斥锁

函数参数
mutex:互斥锁
attr:互斥锁属性 // NULL表示缺省属性

函数返回值     
成功:0
出错:-1

2申请互斥锁函数

所需头文件:#include <pthread.h>
函数原型
int pthread_mutex_lock(pthread_mutex_t *mutex)//申请互斥锁
函数参数
mutex:互斥锁

函数返回值
成功:0
出错:-1

3释放互斥锁函数
所需头文件:#include <pthread.h>
函数原型
int pthread_mutex_unlock(pthread_mutex_t *mutex)//释放互斥锁

函数参数
mutex:互斥锁

函数返回值
成功:0
出错:-1

2、线程间同步

同步(synchronization) 指的是多个任务线程按照约定的顺序相互配合完成一件事情;


线程间同步——P/V 操作

信号量代表某一类资源,其值表示系统中该资源当前可用的数量。

信号量是一个受保护的变量,只能通过三种操作来访问:
1)初始化
2)P操作申请资源
3)V操作释放资源PS含义如下:
if (信号量的值大于0){
        请资源的任务继续运行;  
        信号量的值 减一;  
    }else{
        请资源的任务阻塞;  
    }

VS含义如下:
if (没有任务在等待该资源){
  信号量的值 加一;
}else{
  唤醒第一个等待的任务,让其继续运行;
}

1、信号量初始化函数:

所需头文件:#include <semaphore.h>
函数原型
int sem_int (sem_t *sem,int pshared,unsigned int value)//初始化信号量

函数参数
sem:初始化的信号量
pshared:信号量共享的范围0:线程间使用 非0 :进程间使用
value:信号量初值

函数返回值
成功:0
出错:-1

2、P操作

所需头文件     #include <semaphore.h>
函数原型     
int sem_wait (sem_t *sem) //P操作
函数参数
sem:信号量

函数返回值
成功:0
出错:-1

3、V操作

所需头文件:#include <semaphore.h>
函数原型
int sem_post(sem_t *sem) //V操作
函数参数     
sem:信号量

函数返回值
成功:0
出错:-1



上面说完内置的线程,再来看看Posix多线程的情况


一、线程与多线程的定义

线程存在于进程当中,是操作系统调度执行的最小单位。说通俗点线程就是干活,多线程也就是同时可以干不同的活而且还不会互相打扰,线程并没有自己的独立空间。

二、进程与线程的区别与联系

如果说进程是一个资源管家,负责从主人那里要资源的话,那么线程就是干活的苦力。

一个管家必须完成一项工作,就需要最少一个苦力,也就是说,一个进程最少包含一个线程,也可以包含多个线程。苦力要干活,就需要依托于管家,所以说一个线程,必须属于某一个进程。进程有自己的地址空间,线程使用进程的地址空间,也就是说,进程里的资源,线程都是有权访问的,比如说堆啊,栈啊,静态存储区什么的。

线程就是个无产阶级,但无产阶级干活,总得有自己的劳动工具吧,这个劳动工具就是栈,线程有自己的栈,这个栈仍然是使用进程的地址空间,只是这块空间被线程标记为了栈。每个线程都会有自己私有的栈,这个栈是不可以被其他线程所访问的。

从上面我们知道了进程和线程区别,使用多线程首先是要和进程相对比,它是一种非常便捷的多任务操作方式;我们知道,在Linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种"昂贵"的多任务工作方式。而运行于一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。据统计,总的说来,一个进程的开销大约是一个线程开销的30倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其它线程所用,这不仅快捷,而且方便。当然,数据的共享也带来其他一些问题,有的变量不能同时被两个线程所修改,有的子程序中声明为static的数据更有可能给多线程程序带来灾难性的打击,这些正是编写多线程程序时最需要注意的地方。

线程操作相关的函数:

(1)线程创建函数

int pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*func) (void *), void *arg);

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

(2)线程等待的函数

int pthread_join (pthread_t tid, void ** status);

参数说明:
pthread_join用于等待某个线程退出,成功返回0,否则返回Exxx为正数。
pthread_t tid:指定要等待的线程ID。
void ** status:如果不为NULL,那么线程的返回值存储在status指向的空间中这就是为什么status是二级指针的原因!这种才参数也称为“值-结果”参数。

(3)获得线程自身的ID的函数

pthread_t pthread_self (void);

pthread_self用于返回当前线程的ID。

(4)线程分离的函数

int pthread_detach (pthread_t tid);

pthread_detach用于是指定线程变为分离状态,就像进程脱离终端而变为后台进程类似。成功返回0,否则返回Exxx为正数。变为分离状态的线程,如果线程退出,它的所有资源将全部释放。而如果不是分离状态,线程必须保留它的线程ID,退出状态直到其它线程对它调用了pthread_join。

(5)退出线程(终止线程)的函数

void pthread_exit (void *status);

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

参数说明:
void *status:指针线程终止的返回值。

Linux内核只提供了轻量进程的支持,限制了更高效的线程模型的实现,但Linux着重优化了进程的调度开销,一定程度上弥补了这一缺陷。目前最为流行的线程机制Linux Threads所采用的就是线程-进程“一对一”模型,调度交给核心,而在用户级实现一个包括信号处理在内的线程管理机制。

线程编程实例:pthread.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void *thread_1(void *arg){
    printf("thread one\n");
}
void *thread_2(void *arg){
    printf("thread two\n");
}
int main(){
    int ret = 0;
    pthread_t pid = 0;
    pthread_t pid1 = 0;
    //创建线程1
    ret = pthread_create(&pid, NULL, thread_1, NULL);
    if(ret < 0){
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }
    //创建线程2
    ret = pthread_create(&pid1, NULL, thread_2, NULL);
    if(ret < 0){
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }
    //等待线程退出
    pthread_join(pid, NULL);
    pthread_join(pid1, NULL);
    return 0;
}

注意,在gcc中,默认是不包含线程相关的库的,所以在编译这个程序操作如下是会产生错误的,如下图所示。


图-gcc编译中没有包含线程库的验证结果

正确的编译方式是下面这样,要加上-lpthread这个库,确保编译的时候链接上,如下图所示。


图-创建线程

运行结果,如下图所示。


图-创建线程的实验结果

pthread.c创建了2个线程,并在线程中实现打印功能,最终调用pthread_join等待子线程运行结束,一并退出。

通过上面的一个例程会发现一个问题,当我们去操作一个文件的时候会出现一个问题,就是不知道该听谁的,这时候我们就需要一个互斥锁,让线程一个个来,a执行完之后在执行b。

互斥锁例程:pthread2.c
 #include <stdio.h>
 #include <string.h>
 #include <stdlib.h>
 #include <unistd.h>
 #include <pthread.h>
 //定义一个互斥锁变量
 pthread_mutex_t m;
 void *thread_1(void *arg){
    //互斥锁加锁
    pthread_mutex_lock(&m);
    printf("thread one\n");
    //互斥锁解锁
    pthread_mutex_unlock(&m);
}
void *thread_2(void *arg){
    pthread_mutex_lock(&m);
    printf("thread two\n");
    pthread_mutex_unlock(&m);
}
int main(){
    int ret = 0;
    //以动态方式创建互斥锁
    ret = pthread_mutex_init(&m, NULL);
    if(ret < 0){
        perror("pthread_mutex_init");
        exit(EXIT_FAILURE);
    }
    pthread_t pid = 0;
    pthread_t pid1 = 0;
    ret = pthread_create(&pid, NULL, thread_1, NULL);
    if(ret < 0){
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }
    ret = pthread_create(&pid1, NULL, thread_2, NULL);
    if(ret < 0){
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }
    pthread_join(pid, NULL);
    pthread_join(pid1, NULL);
    return 0;
}

一样的,同样执行编译加上-lpthread参数保证编译时链接线程库,然后运行,如下图所示。


图-添加互斥锁测试

线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该函数的某个数据时,进行保护,其它线程不能进行访问直到该线程读取完成,其它线程才可以使用。不会出现数据不一致或者数据污染。


本文总结自杨源鑫的博客,感谢原作者。