linux系统编程-线程概述

目录

概述

线程数据类型

errno

创建线程

获取线程ID(pthread_t)

终止线程

连接(join)

分离(detach)


概述

线程是相对于进程的概念,进程是系统分配资源的基本单位,有了进程,就可以解决程序独立运行的问题,但是无法高效解决程序内部并发执行多个任务的问题,进程相对线程而言,要面临多个问题:

  • 进程是资源分配的单位,每次创建进程,都要给进程分配虚拟地址空间、页表、文件描述符等资源,这个过程就像搭建一个工厂一样麻烦(而线程可以类比工厂里面的设备,可以实际生产,但是又很轻量,可以根据需要来购进设备 -- 创建线程)
  • 进程通信成本极高,进程之间可以通过信号、管道、文件、或则套接字来进行通信,必须通过一个中间体,而无法直接访问对方进程的内存空间来读取数据,这主要是为了进程运行时安全。
  • 进程切换效率极低,每次进程切换,意味着需要保存完整的上下文来(内存映射、寄存器、页表等信息),再加载此进程的时候又要把他们的上下文给重新加载出来,cpu消耗极大,并发能力有限。
  • 进程任务执行的粒度太大,如果进程被阻塞,那么就代表这个任务就停滞,但是如果将这个进程的任务拆分给多个线程来执行,那么可以配备专门的线程来进行io等会被阻塞的操作,其他的任务可以在io线程被阻塞的时候继续运行。

线程就此而生,线程的内存模型如下图所示,线程共享进程的内部资源:

线程共享进程资源体现在:

  • 进程ID,任何线程中执行获取pid的操作,都会返回线程所属进程的进程ID。
  • 进程组ID,情况同进程ID。
  • 打开文件描述符。
  • 信号处置。
  • 资源限制。
  • 等。。。

线程独有的属性如下(列出一部分):

  • 线程ID。
  • 信号掩码。
  • 线程特有数据(类似于Java的ThreadLocal)
  • errno变量。
  • 线程栈。
  • 等。。。

我们在线程中调用malloc()申请的内存,属于整个进程的全局堆区,而非线程私有,只有线程的局部变量(非malloc或者calloc申请的堆区内存空间),才属于线程的栈区。因此我们应小心使用别的线程栈上返回的指针类型的数据。因为这个数据如果不是属于进程全局堆区,那么将无法保证他是否会在线程栈销毁之后,依然存在。

学习线程最主要就是学习线程相关的函数和线程的内存模型,线程的模型已经有了一个大概的了解,后续的模型的学习会贯穿在线程函数的学习中。接下来我们主要介绍线程相关的函数和数据类型。

线程数据类型

线程的数据类型被设计为不透明类型,其本质是对一些基本数据类型或者结构体进行类型定义,你无需了解里面实现的细节,也就是你不需知道类型定义他的原本的数据类型是什么,因为不同的平台有不同的实现,因此为了保证跨平台移植,不建议将内置的类型转化为他原来的类型来使用,因为你无法保证其他平台也是同样实现这个类型定义。

但是可以大概了解一下他们的实现,这对我们变成也是有帮助的:

  • pthread_t类型,早期是unsigned long,现在可能是指向线程描述符的指针(struct pthread*)类型。正是这种实现的不确定性,编程的时候才不能直接拿==去判断两个pthread_t类型是否相等。因此,具体的平台会帮你实现这个功能,你可以调用pthread_equal()去比较。
  • 其他的类型差不多也都是整形或者结构体,其实现都遵循不透明数据类型的规范,由平台提供给你这些自定类型的各种函数,以便于你去使用。具体数据类型的实现不一一讲解,主要是看类型的功能。

errno

在一般的编程里面,errno被视为一个全局整型变量,这肯定无法满足多线程的要求的,例如如果多个线程发生错误并记录全局的errno,那么errno会以最后一个生产的errno为主,最先生成的errno被后面的errno覆盖了。

因此多线程里面,每个线程都有自己的errno,具体的使用方法和在单线程中使用一样,使用errno之前必须先引入errno.h的头文件(无论是单线程还是多线程,都需要引入这个头文件)。

这个头文件中,并不是单纯的定义了一个全局变量,其本质是一个宏定义,展开之后是一个函数调用,如下:

cpp 复制代码
// <errno.h> 中的核心宏定义
#define errno (*__errno_location())

可以看到你每次使用errno,其本质都是先调用一个函数,这个函数返回一个指针类型,然后对其解引用得到值,并非定义的全局变量。他返回的是线程私有的errno。

创建线程

你编写好一个.c文件之后去运行,这个进程开始只会有一个线程,被称之为main线程,也叫主线程,在主线程中,你可以写下创建线程的函数代码以此在main线程中创建更多的线程。

使用函数创建的声明如下:

cpp 复制代码
#include <pthread.h> // 必要的头文件
int pthread_create(
    pthread_t *thread, 
    const pthread_attr_t *attr,
    void *(*start_routine) (void *), 
    void *arg
);

其参数说明如下:

  • threadpthread_t* 类型,这个参数没有被const修饰,在函数调用过程中会被修改,用来记录创建的线程的唯一ID。
  • attr,线程的属性(线程的位置、大小、调度策略、调度优先级等),填入NULL表示使用线程的默认属性,一般填NULL即可。
  • start_routine,函数指针,参数为void* 类型,返回值也为void* 类型,表示线程的任务,线程在被创建之后就会去执行start_routine函数里面的内容
  • arg,start_routine的参数,为void*类型,理论可以穿任意数据类型,只要在start_routine中转回特定的数据类型即可。

下面是一个创建线程,让这个线程执行打印hello world的例子:

cpp 复制代码
#include <pthread.h>
#include <stdio.h>

void* start_route(void* arg) {
    printf("hello world\n");
}

int main()
{
    pthread_t thread;
    // thread 记录创建的线程id
    // 属性设置为NULL,表示使用默认属性,
    // start_route 函数指针
    // start_route的参数设置为NULL
    pthread_create(&thread, NULL, start_route, NULL);

    // 连接创建的线程
    pthread_join(thread, NULL);
    return 0;
}

编译并运行:

bash 复制代码
des@des-virtual-machine:~/test2$ gcc pthread_create_example.c -o pthread_create_example
des@des-virtual-machine:~/test2$ ./pthread_create_example 
hello world

需要注意的是,pthread_create函数执行之后,现在这个进程就有两个线程了,此时你无法确定哪个线程会先执行,他们遵循着和进程一样的异步执行特性。

此外:

  • arg参数本为start_route的入参,但是此处将arg设为NULL,表示start_route没有参数,因此线程进入到start_route并执行的时候,形参arg就是NULL。但如果不为空,需要根据传入的类型,来将其强转为对应的类型,为确保可移植性,需要在传入参数那里将arg强转为void*,以免出现类型不匹配错误,同时在函数体里面也应该严格转回原来的数据类型。
  • 后面的pthread_join是连接操作,你可以简单的理解为,main线程执行到pthread_join之后,就会阻塞在这一句代码,然后等待新建的线程执行完毕,pthread_join才会停止阻塞继续运行。
  • start_route的返回值也是一个void*类型,在我们的代码中并没有显式的写return 语句,这是不推荐的。但是还需要注意,一个线程被取消的时候,其返回值相当于return -1,这和线程取消的宏定义返回值一样,需要注意区分。

获取线程ID(pthread_t)

进程内部每个线程都有一个唯一标识,称为线程id。

创建线程之后,这个线程的ID就会被存储在调用者传入的pthread_t类型的变量中,此时调用者就可以通过这个ID来找到此新建线程的线程ID。

当然也可以通过函数pthread_self来获得。pthread_self函数会返回调用者的线程ID(pthread_t类型)。

其函数声明如下:

cpp 复制代码
#include <pthread.h>
pthread_t pthread_self(void);

返回值为pthread_t类型,没有参数。这个方法比较简单,就不列举代码案例。

这个线程ID有何用?不同的线程需要利用ID来进行表示目标进程,包括函数pthread_join,pthread_detach,pthread_kill等,都需要显示的指定目标线程ID。

当然这些线程ID不光用于官方库,还可以用于自定义的函数和数据结构。

仍然需要注意的是,两个线程ID(pthrea_t类型)的比较不能直接用==来比较,而是要用专门的函数pthread_equal来比较。其函数声明如下:

cpp 复制代码
#include <pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2);

如果两个相等,该函数就返回一个非零值,否则返回0。

以下的代码是一个返利,它并不具备良好的可移植性:

可以看到,你默认pthread_t类型为一个long类型,但是不同的平台具有不同的实现,可能这个设备可以运行,放到下一个unix设备就无法运行了。

内核线程ID vs 线程ID(pthread_t)

我们说了这么半天的pthread,这个p就是指的posix的意思,也就是posix thread,他是posix库下的多线程的具体实现,可以通过pthread_self来获取自己的ID,但是此ID非彼ID,它和在linux系统上调用getid()系统调用返回的ID并不是同一个东西,getid()系统调用返回的ID是内核维护的数据,一般程序无需知道内核的线程ID。

终止线程

可以由以下这几种方法结束掉线程:

  • 线程执行到return语句,并有return指定返回值
  • 线程调用pthread_exit()函数(本节重点
  • 调用pthread_cancel()来取消函数
  • main线程执行了return语句,或者任意线程调用了exit()函数(exit函数是终止进程的意思,源于stdlib.h库,无论在哪个线程,都会结束进程)

接下来我们讲解pthread_exit函数,其函数声明如下:

cpp 复制代码
#include <pthread.h>
void pthread_exit(void *retval);

其中:

  • retval,是return value的缩写,表示返回值,通过传入一个指针,来记录返回值。

具体代码案例如下:

我们创建一个线程,让线程返回一个申请在堆区的字符串。在新建的线程中使用pthread_exit返回此字符串给主线程,主线程来打印此字符串。

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void* thread_function(void* arg) {
    char* ret = (char*)malloc(sizeof(100));
    ret[0] = 'h';
    ret[1] = 'e';
    ret[2] = 'l';
    ret[3] = 'l';
    ret[4] = 'o';
    ret[5] = ' '; // 空格字符
    ret[6] = 'w';
    ret[7] = 'o';
    ret[8] = 'r';
    ret[9] = 'l';
    ret[10] = 'd';
    ret[11] = 0;
    pthread_exit((void*)ret);
    return NULL;
}

int main()
{
    pthread_t t;
    pthread_create(&t, NULL, thread_function, NULL);

    char* s;
    pthread_join(t, (void**)&s);
    if (s == NULL) {
        printf("pthread_exit返回值未生效\n");
    }
    printf("main: %s\n", s);
    return 0;
}

执行结果如下:

bash 复制代码
des@des-virtual-machine:~/test2$ gcc pthread_exit_example.c -o pthread_exit_example
des@des-virtual-machine:~/test2$ ./pthread_exit_example 
main: hello world

可以看到,我在线程执行的函数中,在pthread_exit函数后面加了一个return语句,如果返回值为null,那就说明pthread_exit的返回值并没生效,但是从结果来看,pthread_exit返回值确实生效了,并没有返回null。

在线程里面调用pthread_exit相当于在线程中执行了return语句,不同之处在于,线程可以在启动函数(start_route)中的或者任意子函数中使用,无论这个线程在调用pthread_exit之前有多少个函数调用栈帧,都会退出当前线程,而return语句仅仅只是返回到上一层的函数栈帧中。

同样需要注意的是,不能在线程私有栈中申请一块非malloc和calloc的内存空间并把它返回给接收的线程,因为你并不能知道这个空间是否可用,这个局部变量的生命周期是随着线程栈帧的销毁而销毁的。

同样,如果在main线程的任何代码中执行了pthread_exit,那么无论如何这个进程都会结束执行,在mian函数中执行return有同样的效果,但是需要注意不是在main线程里面运行的return语句也仅仅只是推出一个函数。

连接(join)

函数pthread_join()等待由pthread_t标记的线程,如果线程依旧终止,那么就会立即返回,否则会线程会阻塞等待。简单来说就是pthread_join这个函数的参数里面指定了一个线程的pthread_t,某个线程调用了pthread_join,就会阻塞等待这个线程ID为传入参数的线程执行结束,如果这个线程已经结束,那么就会立马返回。

其函数声明如下:

cpp 复制代码
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);

其中thread的类型pthread_t,表明线程ID,retval为传入传出参数,表明返回值,返回值使用retval来接收。

其函数返回值如果为0表示调用成功,否则就会返回一个错误码。

下面我们在main线程里面创建一个子线程,然后子线程执行每秒1次的打印,持续20秒,如下:

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 每秒执行一次打印,持续20秒
void* start_route(void* arg) {
    printf("新线程开始执行\n");
    for(int i = 0; i < 20; i++) {
        printf("this is in new thread: i = %d\n", i);
        sleep(1);
    }
    printf("新建线程执行结束\n");
}
int main()
{
    pthread_t t1;
    pthread_create(&t1, NULL, start_route, NULL);
    return 0;
}

但是其输入却是如下:

可以看到什么都没输出。我们之前提到过,main线程中执行return语句或者pthread_exit(),会直接导致main线程结束,main线程结束代表进程主流程结束,随之进程也会结束,进程的其他线程都会被回收,所以没有打印,因此我们需要然后新建线程连接上main线程,由main线程去调用pthread_join然后在参数中指明新建线程的pthread_t,也就是线程ID:

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 每秒执行一次打印,持续20秒
void* start_route(void* arg) {
    printf("新线程开始执行\n");
    for(int i = 0; i < 20; i++) {
        printf("this is in new thread: i = %d\n", i);
        sleep(1);
    }
    printf("新建线程执行结束\n");
    return NULL;  // 标准做法应该是在不需要返回值时候,返回NULL。
}
int main()
{
    pthread_t t1;
    pthread_create(&t1, NULL, start_route, NULL);
    pthread_join(t1, NULL);
    printf("main线程执行完毕\n");
    return 0;
}

输出结果如下:

bash 复制代码
des@des-virtual-machine:~/test2$ ./pthread_join_example
新线程开始执行
this is in new thread: i = 0
this is in new thread: i = 1
this is in new thread: i = 2
this is in new thread: i = 3
this is in new thread: i = 4
this is in new thread: i = 5
this is in new thread: i = 6
this is in new thread: i = 7
this is in new thread: i = 8
this is in new thread: i = 9
this is in new thread: i = 10
this is in new thread: i = 11
this is in new thread: i = 12
this is in new thread: i = 13
this is in new thread: i = 14
this is in new thread: i = 15
this is in new thread: i = 16
this is in new thread: i = 17
this is in new thread: i = 18
this is in new thread: i = 19
新建线程执行结束
main线程执行完毕

有几点需要注意:

  • pthread_join函数的返回值为什么是一个void** 类型?作为返回值,按理来说,传入void*就足够完成传入传出参数的功能,为何多此一举?

我们先来使用一个正确的例子,来看看如何使用返回值,我们在main线程中创建一个线程,然后让这个线程返回字符串"hello world",然后main线程接收并打印这个字符串:

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>

void* start_route(void* arg) {
    char* str = (char*)calloc (100, sizeof(char));
    str[0] = '@';
    str[1] = 'h';
    str[2] = 'e';
    str[3] = 'l';
    str[4] = 'l';
    str[5] = 'o';
    str[6] = ' ';
    str[7] = 'w';
    str[8] = 'o';
    str[9] = 'r';
    str[10] = 'l';
    str[11] = 'd';
    return str;
}

int main()
{
    pthread_t t1;
    char* str = NULL;
    pthread_create(&t1, NULL, start_route, NULL);
    pthread_join(t1, (void**)&str);
    printf("main: %s\n", str);
    return 0;
}

输出:

cpp 复制代码
des@des-virtual-machine:~/test2$ ./pthread_join_retval 
main: @hello world

正常输出。那为何不用一级指针?我们来思考一下:

  • 第一,pthread_join这个函数设计是具有通用性的,所谓通用性就是设计这个函数的人,肯定不希望他只能传递一种数据类型的返回值。所以为了通用性,肯定有限是传递的通用指针类型,也就是void*或者void**。
  • 其次,如何从void*和void**中做出抉择?先来假设pthread_join如果是void*类型,我们需要注意些什么?
    • a. 传入的事void*,那么要达到传入传出参数方式的返回值的效果,就必须修改void*指向的内存空间的值来完成返回值传递,那么关键的一步就是,通用指针类型的直接访问是c语言的未定义行为,简而言之就是,你如何知道这个void*所指向的内存大小是多少?所以必须还要新增一个参数来表示大小。
    • b. 其次假如这个传入的是野指针,你该如何处理?其子线程中根本就无法判断是否为野指针,最多判NULL。
  • 综上,选择二级指针是明智的,二级指针可以直接把main线程中的传入的一级指针的指向改为线程返回的内存空间,从而避免两边数据空间大小的不确定性。
  • 如果调用者调用pthread_join的时候,传入了一个已经被join了的线程,将会触发未定义行为。

比如main线程调用pthread_join连接了ID为1000的线程,此后main再次连接这个ID为1000的线程,但是这个线程可能在第二次join的时候已经被回收了(被回收之后ID也会被回收然后给其他线程使用),此时join的并非原来的那个线程。

  • 连接也不是任意的,是有限制的,如果随意连接,那么将会发生不安全和未定义行为。比如某个线程去连接由库函数私自创建的线程。

线程应该尽量连接他们所"熟悉"的线程。

分离(detach)

既然有连接,让一个线程的执行结果和顺序和一个线程深度绑定,那么肯定也可以解绑,正如所言,posixc提供了分离的方法,其函数声明如下:

cpp 复制代码
#include <pthread.h>
int pthread_detach(pthread_t thread);

依旧,只有一个类型为pthread_t的入参,指明一个线程ID为thread的线程。成功返回0,否则返回错误码。

默认情况,线程是可连接的,线程退出时,可以使用pthread_join来获取其返回状态。但是并不是所有的线程都需要关注其返回状态,比如有一些清理线程,我只需要他从头到尾执行清理函数即可,无需什么返回值,此时就可以将这个线程分离,让其在结束的时候由操作系统回收其内核数据。

线程分离的时机:

  • 在pthread_create函数中的属性选项中指定初始状态为分离状态。
  • 一个线程在创建另外一个线程之后,立马调用pthread_detach函数将其分离。
  • 一个线程被创建之后,在其内部先使用pthread_self获取自身的线程ID,然后调用pthread_detach自己分离自己。

线程刚开始是可连接的,但是也是可分离的,也就是说一个线程刚被创建得时候,可以被join,也可以被detach,但是二者只能选其一,join之后就不能detach了,同理detach之后就不能重新join了。

但是需要注意的是,即使是分离,依旧会收到main线程的影响,main线程执行return语句,或者main线程调用pthread_exit函数,再或者main线程异常结束,都会导致整个进程结束,当然也包括被分离的线程。

相关推荐
青树寒鸦2 小时前
wsl的docker备份mongo和迁移
运维·mongodb·docker·容器
Bdygsl2 小时前
Linux(12)—— 文件与文件描述符
linux·运维·服务器
前端玖耀里2 小时前
Linux C/C++ 中系统调用与库函数调用的区别
linux·c语言·c++
艾莉丝努力练剑2 小时前
【Linux:文件】基础IO:文件操作的系统调用和库函数各个接口汇总及代码演示
linux·运维·服务器·c++·人工智能·centos·io
松涛和鸣2 小时前
70、IMX6ULL LED驱动实战
linux·数据库·驱动开发·postgresql·sqlserver
狂野小青年2 小时前
Jenkins如何添加全局凭证
运维·jenkins
m0_694845572 小时前
music-website 是什么?前后端分离音乐网站部署实战
linux·运维·服务器·云计算·github
you-_ling3 小时前
Linux软件编程:Shell命令
java·linux·服务器
FairGuard手游加固3 小时前
面具外挂检测方案
linux·运维·服务器