线程(二)【线程控制】

目录

  • [1. 创建线程](#1. 创建线程)
  • [2. 线程等待](#2. 线程等待)
  • [3. 线程终止](#3. 线程终止)
  • [4. 重谈线程函数及其参数](#4. 重谈线程函数及其参数)
  • [5. 线程ID及进程地址空间布局](#5. 线程ID及进程地址空间布局)

线程(一)【理论篇】 在铺垫完线程全部的理论后,线程是在进程内部运行的,并且共享进程的资源,所以我们不难得知,如果对一个线程 getpid,那么得到的 PID 与进程(即主线程)应该是一致的。

由于线程 PCB 的 PID 与进程是一致的,那么操作系统在调度线程时,就无法根据 PID 对线程进行调度,所以线程除了 PID,还有一个线程自己的 ID(即 LWP)。

而由于 linux 这款操作系统的线程,是通过进程的内核数据结构模拟的,在 linux 中没有针对线程设计新的内核数据结构,只有所谓轻量级进程的概念,所以就注定了 linux 系统没有向上层用户提供关于线程接口的系统调用。而作为上层用户,确实有对多线程编码的需求,因此 linux 开发程序员就在应用层开发了一个线程库(编译时需要手动链接库,因为 pthread 不是系统库,也不是语言库,是第三方库),为用户来提供控制线程的相关接口。

1. 创建线程

NAME
      pthread_create - create a new thread

SYNOPSIS
      #include <pthread.h>

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

      Compile and link with -pthread.

RETURN VALUE	// 成功返回 0,识别返回 errno,但是 errno 不会被设置
       On success, pthread_create() returns 0; on error, it returns an error number, and  the  con‐tents of *thread are undefined.

thread:一个输出型参数,返回线程ID
attr:设置线程的属性,attr 为 null 表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数(即线程执行的代码块,可以理解为这个函数就是线程的入口函数)
				void* 的返回类型是为了支持返回所有类型的指针,可以理解为 void* 是C式泛型
arg:传递给线程启动函数 start_routine 的参数
cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

void *threadRun(void* args)
{
    while(1)
    {
        std::cout << "new thread: " << getpid() << std::endl;
        sleep(1);
    }
    return nullptr;     // 线程不会返回到 main 函数,执行完线程函数后 就退出了。
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRun, nullptr);
    while(1)
    {
        std::cout << "main thread: " << getpid() << std::endl;
        sleep(1);
    }
}

现象:正如我们文章一开始的分析,线程是进程的一个执行分支,是在进程的地址空间内运行的,共享的是进程的资源,因此即便线程有自己的 task_struct,但它没有自己的 PID。

ps -aL: 查看当前用户启动的所有轻量级进程

并且当进程运行起来后,我们看到了两个执行流,它们两个的 LWP 是不一样的。我们之前一直说,操作系统是根据进程的 PID 进行调度的,其实不然,因为在如今的场景(多执行流),显然无法区分进程和线程。操作系统是根据 LWP 对执行流进行调度的。

我们之前都是单进程单执行流的情况,所以我们之前说是根据 PID 进行调度的也没错,因为主线程的 PID 与 LWP 是一致的,根据 PID 或者 LWP 对主线程调度,效果都是一样的。 而为什么主线程的 PID 与 LWP 是一致的,因为它是第一个被创建的,程序运行起来就被创建了 。

现象:无论是对主线程发送 9 号信号,还是对新线程,整个进程都是直接被干掉,这就是我们在 线程(一)【理论篇】 所说的,由于线程是进程的一个执行分支,所以无论哪个线程异常(收到信号),本质就是进程收到信号,信号最终是由进程这个整体去处理的,因此最终影响的也是整个进程。这也说明了线程的健壮性很差,只有有一个执行流出问题了,整个进程就都完蛋。

cpp 复制代码
int g_val = 0;
int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRun, nullptr);
    while(1)
    {
    	printf("main thread pid: %d, g_val: %d, &g_val: 0x%p, create new thread tid: %p\n", getpid(), g_val, &g_val, tid);
        ++g_val;
        ...
    }
}

现象:主线程对全局变量做修改,新线程能够看到,即无论是否初始化,全局区的数据都是被多个线程所共享的。并且我们看到,所获取到的线程 tid 并不是 LWP,这是因为 LWP 是操作系统层的概念,即操作系统在内部对线程的标识符,用户不需要关心,用户只需要关心线程的 tid。


2. 线程等待

在 linux 系统中,关于父子进程谁先被操作系统调度,这是无法确定的,同理,线程在 linux 中是轻量级线程,主线程和新线程谁先被调度,也是无法确定的,都是取决于调度器。在父子进程中,父进程需要最后一个退出,因为它需要等待子进程退出后回收子进程,同样的,在多线程下,主线程也需要最后退出,因为它也需要等待它所创建出来的新线程,创建线程的本质是对线程做管理,所以主线程同样需要管理回收新线程。简言之,线程退出时也要被等待!

在父子进程体系中,如果父进程先退出,子进程退出时就无人等待回收,那么子进程就会一直陷入僵尸状态;类似的,如果主线程不对创建出来的线程做等待,那么新线程退出后,也会造成 类似 于僵尸进程的问题(虽然无法观察到这个问题)。

用户创建一个子进程,目的就是为了让子进程去执行任务,如果有需要,最后子进程退出时,要把执行情况告知上层用户;同样的,用户创建一个线程也是为了让线程去干活!所以如果用户需要,那么线程在退出时,也要把结果能够带回上层!

NAME
      pthread_join - join with a terminated thread	

SYNOPSIS
      #include <pthread.h>

      int pthread_join(pthread_t thread, void **retval);	

RETURN VALUE    
      On success, pthread_join() returns 0; on error, it returns an error number.
      
thread:线程ID
retval:指向一个指针,所指向的指针指向的是线程的返回值数据
cpp 复制代码
void *threadRun(void* args)
{
	...
    return (void*)100;     // 线程的退出结果由返回值带回
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRun, nullptr);
    sleep(7);
    void* retval;
    pthread_join(tid, &retval);		// main thread 默认是阻塞等待,即新线程不退,主线程就一直等
    cout << "main thread quit!" << (long long)retval << endl;   // 64位平台下指针8字节,强转int会发生数据截断,gcc报错

    return 0;
}

之前我们在介绍 进程的创建、终止 时说过,一个进程(执行流)执行的结果就三种情况:代码正常运行,结果正确;代码正常运行,结果不正确;代码运行时异常终止。

在线程函数内将线程的执行结果返回给主线程的前提是,代码正常运行没有出现异常,中间出现异常,就运行不到返回结果那一刻了。那线程出异常了怎么返回?怎么不像多进程那样在 wait 子进程时,可以通过 status 参数将异常终止信号以及退出结果返回给父进程。

线程确实可能存在代码运行时异常终止的情况,但是线程出异常,最终整个进程都会被干掉,可不像多进程体系,每个进程之间互相独立,互不影响。 因此在线程等待 pthread_join 时不需要考虑线程异常的情况,线程的异常,最终会被该线程所属进程的父进程处理。


3. 线程终止

线程终止的其中一种方式,我们在线程等待时已经提及了,即线程函数执行完毕做返回时,就是线程的终止。

在介绍其它线程终止的方法时,我们先试试 exit 能不能让线程退出。

cpp 复制代码
void *threadRun(void* args)
{
    ...
    exit(11);   
    return (void*)100;    
}
int main()
{
	...
    pthread_join(tid, &retval);		
    cout << "main thread quit!" << (long long)retval << endl; 
    return 0;
}

现象:"main thread quit!" 这条语句并没有被执行,证明在线程内 exit 时,线程确实退出了,但是整个进程也给干掉了。因为 exit 的作用是终止进程的,因此无法用于终止线程。

NAME
      pthread_exit - terminate calling thread		// 终止一个线程,无返回值

SYNOPSIS
      #include <pthread.h>

      void pthread_exit(void *retval);		// 终止线程时,于 pthread_join 相似,可以将线程的退出结果带回。
cpp 复制代码
void *threadRun(void* args)
{
    ...
    pthread_exit((void*)100);   
}

需要注意的是,如果在主线程内直接 return 退出了,那么主线程创建的全部线程也就随之退出。

终止一个线程,还可以通过取消一个线程来完成。

NAME
      pthread_cancel - send a cancellation request to a thread	// 向线程发送一个取消请求

SYNOPSIS
      #include <pthread.h>
       
      int pthread_cancel(pthread_t thread);	 // 传入线程的 tid 取消线程。目标线程必须是存在的。
cpp 复制代码
int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRun, (void*)"Thread[1]");
    sleep(1);   // 1s后取消线程
    pthread_cancel(tid);
    ...
}

现象:由于主线程中设置了 1s 后取消 tid 目标线程,因此目标线程无法正常执行完毕。同时我们看到了线程的退出结果被默认设置为 -1,这是因为 pthread_cancel 的退出结果为 PTHREAD_CANCELED(一个宏),即 #define PTHREAD CANCELED((void *)-1)


4. 重谈线程函数及其参数

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

在创建线程的时候,不仅仅可以传递字符串参数给线程函数,包括各种类对象都是可以的,以及线程函数的返回值也可以是类对象这种。

接下来,我们创建一个线程并计算一段区间的累加和,计算过程和结果封装为类,作为线程函数的参数传递和返回值传出。

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cstdlib>
using namespace std;

class Response
{
public:
    Response(int result, int exitcode)
        :_result(result), _exitcode(exitcode)
    {}
public:  
    int _result;   // 计算结果
    int _exitcode; // 计算结果是否可靠
};

class Request
{
public:
    Request(int start, int end, const string &threadname)
        :_start(start), _end(end), _threadname(threadname)
    {}

    long long CalSum(Response* resp)
    {
        for(int i = _start; i <= _end; i++)
        {
            // cout << rq->_threadname << " is runing, caling..., " << i << endl;
            resp->_result += i;
            // usleep(100000);
        }
    }

public:   
    int _start;
    int _end;
    string _threadname;
};

void *sumCount(void *args) // 线程的参数和返回值,不仅仅可以传递一般参数,也可以传递对象
{
    Request *req = static_cast<Request*>(args);     
    Response *resp = new Response(0,0);
    req->CalSum(resp);
    delete req;
    return resp;
}

int main()
{
    pthread_t tid;
    Request* req = new Request(1, 100, "thread 1");
    pthread_create(&tid, nullptr, sumCount, req);

    void* ret;
    pthread_join(tid, &ret);
    Response *resp = static_cast<Response*>(ret);
    cout << "resp->result: " << resp->_result << ", exitcode: " << resp->_exitcode << endl;
    delete resp;
    return 0;
}

在这份案例中,我们在主线程中 new 了一个类对象传递给新线程, 在新线程中也 new 了一个类对象传递给主线程,并且这些对象在主线程和新线程中都可见、可访问。这就说明了地址空间中的堆空间也是被线程所共享的资源!


5. 线程ID及进程地址空间布局

因为 Linux 内核中没有明确的线程的概念,只有轻量级进程的概念,所以操作系统也没有直接给上层提供线程控制的相关接口。但是操作系统中有一个 clone 接口用于创建轻量级进程。

NAME
      clone, __clone2 - create a child process

SYNOPSIS
      /* Prototype for the glibc wrapper function */

      #include <sched.h>

      int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...
                 /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

*fn:指向线程执行函数的函数指针,pthread 库中的 pthread_create 创建一个线程时传递的线程函数就是用于给 clone 传递的
*child_stack:线程的自定义栈

只不过,这个 clone 系统接口,作为用户是无法直接使用的,所以被 pthraed 线程库所封装,供上层使用。而因为系统底层接口 clone 创建一个轻量级进程的参数需要,所以我们用户在使用线程库时,需要传递诸如线程的回调函数以及用户空间(线程执行的代码、运行过程中形成的临时变量等数据的存放空间)。所以这就说明了,虽然操作系统内部没有明确的线程的概念,但是在上层,我们是有的,我们在使用线程库的接口传递参数时,概念上就是,这是线程的回调函数,这是线程的栈空间等等。因此线程的概念是线程库来维护的!

因为线程库要维护上层用户对线程的所有概念(诸如线程的ID,线程的栈空间,各种字段属性等等)

线程库是一个动态库,所以当我们在执行线程库的代码时,线程库也要被加载到内存中!而线程是在进程内执行的,是进程的一个执行分支,所以这个线程库最终经过页表映射到进程的地址空间中的共享区!当我们在进程中创建一个线程,那么在 pthread 库中就要给我们开辟一段空间,用于充当新线程的栈空间,即,线程的栈空间是在地址空间的共享区中的。

而诸如线程中的线程 ID、栈空间的大小、线程执行的回调函数的地址、线程的时间片、线程的状态等字段属性,linux 操作系统并不关心,因为 linux 中并没有线程这个概念,即线程的概念是线程库来维护的(因为 linux 没有线程的概念,所以它可以不关心,但作为用户需要关心线程的诸多属性,所以线程库需要把用户关心的线程的诸多字段属性给维护起来),这也是为什么我们在调用线程库接口时,需要我们传递线程 ID,设置线程的属性,回调函数等字段。

而不同的进程都能够调用线程库创建线程,因此在系统底层就都需要调用 clone 接口来维护上层创建线程时的独立的栈结构。因此线程库中存在多个线程,那么线程库就需要对这些创建出来的线程做管理。

当上层调用线程库接口创建一个线程,那么线程库就需要在自己库里面创建一个库级别的线程控制块,用于描述线程的回调函数的地址、线程的独立栈空间地址、线程的 LWP 指向内核中的执行流控制块等字段。 所以用户在访问线程时,只需要找到这个线程(即提供线程ID), 那么线程这个执行流就会被操作系统在底层自动调度。而对于这种 由用户层维护的线程,我们称为用户级线程。

用户级线程中的 tcb 里面包含了很多用户关心的线程的属性字段,并且每个线程都有这个结构。即以 tcb 这样的结构在上层把线程的概念描述起来,再通过数组的形式将上层创建的每一个线程进行组织管理。再者,可能用户创建的线程很多,那么就有在线程库中就会存在很多的 tcb,为了人用户快速的找到指定的 tcb,就有了所谓的线程 tid ---- tcb 在地址空间中的起始地址。 将来用户想要访问线程、获取线程的属性等操作,都能通过向线程接口传递 tid 完成。再具体一点,所谓的 tid,就是地址空间中共享区的某一个地址。

  • 为什么每个线程在运行时都有自己独立的栈结构

    因为每一个线程都会有独立自己的调用链(即线程从执行到退出整个过程都调用了哪些函数),而栈结构会保存任何一个执行流,在运行过程中所有的临时变量,比如压栈时、传参时、函数返回时,以及函数内部定义的各种临时变量。

    主线程是直接使用地址空间提供的栈区的,可以理解为这种线程就是真进程,而我们用户自己通过线程库所创建的各种线程,则是所谓的轻量级进程。在创建这种轻量级进程时,首先在线程库中创建描述线程的线程控制块 tcb,tcb 的起始地址即线程 ID,还有线程独立的栈结构,之后调用系统中的 clone 创建执行流,然后把线程的各字段传递给 clone,包括线程的栈结构。换言之,所有非主线程的栈都在线程库/共享区中进行维护。


如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!

感谢各位观看!

相关推荐
aaasssdddd9620 分钟前
C++的封装(十四):《设计模式》这本书
数据结构·c++·设计模式
发呆小天才O.oᯅ26 分钟前
YOLOv8目标检测——详细记录使用OpenCV的DNN模块进行推理部署C++实现
c++·图像处理·人工智能·opencv·yolo·目标检测·dnn
新手上路狂踩坑32 分钟前
Android Studio的笔记--BusyBox相关
android·linux·笔记·android studio·busybox
qincjun1 小时前
文件I/O操作:C++
开发语言·c++
星语心愿.1 小时前
D4——贪心练习
c++·算法·贪心算法
fnd_LN1 小时前
Linux文件目录 --- 复制命令CP、递归复制目录、软连接、硬链接
linux·运维·服务器
汉克老师2 小时前
2023年厦门市第30届小学生C++信息学竞赛复赛上机操作题(三、2023C. 太空旅行(travel))
开发语言·c++
OopspoO2 小时前
Linux查看键鼠输入
linux
single5942 小时前
【c++笔试强训】(第四十一篇)
java·c++·算法·深度优先·图论·牛客
yuanbenshidiaos2 小时前
C++-----函数与库
开发语言·c++·算法