【Linux】线程概念和线程控制-二、线程控制

时间:2024-02-15 21:17:20

1. pthread 线程库

因为 Linux 中没有专门为线程设计一个内核数据结构,所以内核中并没有很明确的线程的概念,而是用进程模拟的线程,只有轻量级进程的概念。这就注定了 Linux 中不会给我们直接提供线程的系统调用,只会给我们提供轻量级进程的系统调用!可是我们用户需要线程的接口,所以在用户和系统之间,Linux 开发者们给我们开发出来一个 pthread 线程库,这个库是在应用层的,它是对轻量级进程的接口进行了封装,为用户提供直接线程的接口!虽然这个是第三方库,但是这个库是几乎所有的 Linux 平台都是默认自带的!所以在 Linux 中编写多线程代码,需要使用第三方库 pthread 线程库!

(1)pthread_create()

接下来我们介绍 pthread 库中的第一个接口,创建一个线程:

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

在这里插入图片描述

其中第一个参数是一个输出型参数,一旦我们创建好线程,我们是需要线程 id 的,所以该参数就是把线程 id 带出来;第二个参数 attr 为线程的属性,我们不用关心,设为 nullptr 即可。

第三个参数是一个函数指针类型,也就是说我们需要传一个函数进去。当我们创建线程的时候,我们是想让执行流执行代码的一部分,那么我们就可以把该线程要执行入口函数地址传进去,线程一启动就会转而执行该指针指向的函数处!关于该函数指针的返回值和参数,都是 void*,因为 void* 可以接收或者返回任意指针类型,这样就可以支持泛型了。而第四个参数 arg 是一个输入型参数,当线程创建成功,新线程回调线程函数的时候,如果需要参数,这个参数就是给线程函数传递的,也就是说该参数是给第三个参数函数指针中的参数传递的。

在这里插入图片描述

而函数的返回值,如果我们创建成功就返回0;如果失败会返回错误码,而没有设置 errno.

最后我们在编译的时候需要加上 -lpthread 指定库名称。

示例代码:

				void* pthread_handler(void* attr)
				{
				    while(1)
				    {
				        cout << "i am a new thread, pid: " << getpid() << endl;
				        sleep(2);
				    }
				}
				
				int main()
				{
				    pthread_t tid;
				    pthread_create(&tid, nullptr, pthread_handler, nullptr);
				
				    while(1)
				    {
				        cout << "i am main thread, pid: " << getpid() << endl;
				        sleep(1);
				    }
				
				    return 0;
				}

在这里插入图片描述

如上图,我们以前写的代码中是不可能出现两个死循环的,但是使用创建线程之后就可以了,这就说明它们是不同的执行流。而它们的 pid 是一样的,就说明它们是同一个进程。

而我们右侧终端中,正在查看两个执行流,其中查看执行流的指令为:ps -aL,我们上面循环打印了方便观察,我们看到 pid 是一样的,但是 LWP 是什么呢?为什么会不一样呢?在 Linux 中没有具体的线程概念,只有轻量级进程的概念,所以 CPU 在调度时,不仅仅只要看 pid,更重要的是每一个轻量级进程也要有自己对应的标识符,所以轻量级进程就有了 LWP (light weight process)这样的标识符,所以 CPU 是按照 LWP 来进行调度的!

但是我们如果杀掉上面任意一个执行流的 LWP,默认整个进程都会被终止,这就是线程的健壮性差的原因。

如果我们定义一个函数,或者全局变量,分别在两个执行流中执行,它们都可以读取到该函数和全局变量,如下代码:

				void Print(const string& str)
				{
				    cout  << str << endl;
				}
				
				void* pthread_handler(void* attr)
				{
				    while(1)
				    {
				        cout << "i am a new thread, pid: " << getpid() << ", val = " << val << endl;
				        Print("i am new thread");
				        sleep(2);
				    }
				}
				
				int main()
				{
				    pthread_t tid;
				    pthread_create(&tid, nullptr, pthread_handler, nullptr);
				
				    while(1)
				    {
				        cout << "i am main thread, pid: " << getpid() << ", val = " << val << endl;
				        Print("i am main thread");
				        val++;
				        sleep(1);
				    }
				
				    return 0;
				}

有关线程的 id 的问题我们后面再谈。

(2)pthread_join()

那么创建线程后是主线程先运行还是新线程先运行呢?不确定,要看CPU先调度谁,那么肯定的是主线程是最后退出的!因为主线程退了整个进程就退出了,所以主线程要进行线程等待!如果主线程不进行线程等待,会导致类似于僵尸进程的问题!而 pthread_join() 就是进行线程等待的接口。

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

在这里插入图片描述

其中第一个参数,为线程的 id;第二个参数 retval 我们先不管,后面再介绍,设为 nullptr 即可。下面我们简单写一个程序:

				void* pthread_handler(void* attr)
				{
				    int cnt = 5;
				    while(cnt--)
				    {
				        cout << "i am a new thread, pid: " << getpid()  << endl;
				        sleep(1);
				    }
				}
				
				int main()
				{
				    pthread_t tid;
				    pthread_create(&tid, nullptr, pthread_handler, nullptr);
				
				    pthread_join(tid, nullptr);
				
				    cout << "main thread quit..." << endl;
				    return 0;
				}

结果如下:

在这里插入图片描述

我们可以看到当新线程在运行的时候,主线程并没有直接运行结束,而是进行阻塞等待!

接下来我们说一下第二个参数 retval;其实我们给线程分配的函数,它的返回值是直接写入 pthread 库中的,而 retval 也是被封装在库中,所以我们可以根据 retval 读取到函数的返回值,也就是说这个 retval 就是一个输出型参数!首先我们需要定义一个 void* 类型的变量,然后将这个变量取地址当作 pthread_join 的第二个参数传入即可!例如以下代码:

				void* pthread_handler(void* attr)
				{
				
				    return (void*)1234;
				}
				
				int main()
				{
				    pthread_t tid;
				    pthread_create(&tid, nullptr, pthread_handler, nullptr);
				
				    void* retval;
				    pthread_join(tid, &retval);
				
				    cout << "main thread quit, retval = " << (long long)retval << endl;
				    return 0;
				}

在这里插入图片描述

(3)pthread_exit()

那么除了在函数中直接 return 终止线程外,还有什么方法吗?有的,pthread_exit() 接口就是用来终止线程的:

				void pthread_exit(void *retval);

在这里插入图片描述

参数就是和 void* 返回值一样。注意线程内不能使用 exit() 系统接口终止线程,因为 exit() 是用来终止进程的!例如:

				void* pthread_handler(void* attr)
				{
				    pthread_exit((void*)1234);
				}

在这里插入图片描述

(4)pthread_cancel()

除了上面的方法,pthread_cancel() 也可以取消一个线程,参数就是目标线程的 id

				int pthread_cancel(pthread_t thread);

在这里插入图片描述

返回值如下:

在这里插入图片描述

如果 thread 线程被别的线程调用 pthread_ cancel 异常终掉, pthread_join 第二个参数 retval 所指向的单元里存放的是常数PTHREAD_ CANCELED,也就是 -1.

(5)简单使用 pthread 库

假设我们现在需要写一个线程进行整数相加,代码如下:

Request 类为一个需求类,_start_end 为需要求的整数相加的范围。

				class Request
				{
				public:
				    Request(int start, int end)
				        :_start(start)
				        ,_end(end)
				    {}
				    ~Request()
				    {
				        cout << "~Request()" << endl;
				    }
				public:
				    int _start;
				    int _end;
				};

Result 类为一个结果的类,Run 方法为求和方法;_result 为计算结果;_exitcode 为记录计算结果是否可靠。

				class Result
				{
				public:
				    Result(int result, int exitcode)
				        :_result(result)
				        ,_exitcode(exitcode)
				    {}
				
				    void Run(int start, int end)
				    {
				        for(int i = start; i <= end; i++)
				        {
				            _result += i;
				        }
				    }
				    ~Result()
				    {
				        cout << "~Result()" << endl;
				    }
				public:
				    int _result;    // 计算结果
				    int _exitcode;      // 计算结果是否可靠
				};

下面为测试代码:

				void* countSum(void* args)
				{
				    Request* rq = static_cast<Request*>(args);
				    Result* res = new Result(0, 0);
				    res->Run(rq->_start, rq->_end);
				
				    return res;
				}
				
				
				int main()
				{
				    Request* rq = new Request(1, 100);
				    pthread_t tid;
				    pthread_create(&tid, nullptr, countSum, rq);
				
				    void* res;
				    pthread_join(tid, &res);
				
				    Result* req = static_cast<Result*>(res);
				    cout << req->_result << endl;
				
				    delete req;
				    delete rq;
				    return 0;
				}

结果如下:

在这里插入图片描述

所以线程的参数和返回值,不仅仅可以用来进行传递一般参数,也可以传递对象!

2. 理解线程库

(1)线程 id

我们上面学习了 pthread_create() 接口,但是第一个参数就是线程的 id,我们至今都没有介绍过它,所以我们可以尝试打印一下看一下它究竟长什么样,如下:

				int main()
				{
				    pthread_t tid;
				    pthread_create(&tid, nullptr, mythread, nullptr);
				    cout << "tid = " << tid << endl;
				
				    pthread_join(tid, nullptr);
				    return 0;
				}

在这里插入图片描述

我们可以看到 tid 是一个非常大的数字,假设我们换成十六进制呢?如下图:

在这里插入图片描述

我们可以看到,它很像一个地址。

如果线程想要获得自己的线程 id,还可以通过线程库中的接口获得,如下:

				pthread_t pthread_self(void);

在这里插入图片描述

返回值就是线程的 id.

那么这个线程 id 究竟是什么呢?因为 Linux 中没有明确的线程概念,所以没有直接提供线程的系统接口,只能给我们提供轻量级进程的系统接口,那么系统中是怎么创建轻量级进程呢?其实是用 clone() 接口,如下:

在这里插入图片描述

其实这个接口就是创建一个子进程,fork() 的底层原理和 clone() 类似,但是 clone() 是专门用来创建轻量级进程的。第一个参数函数指针类型,就是新创建执行流要执行的函数地址入口;第二个参数 child stack 就是自己自定义的栈;第三个参数就是是否让地址空间共享;后面的参数就不用关心了。

所以,这个接口就被线程库封装了,给我们提供的就是我们上面所介绍的线程库的接口。所以,clone() 允许用户传入一个回调函数和一个用户空间,来代表这个轻量级进程运行过程中所执行的代码,它在运行中的临时变量全部放在用户空间栈上。也就是说,线程库需要封装 clone() 的话,线程库中每一个线程都要给 clone() 提供执行方法,还要在线程库中开辟空间。所以,线程的概念是库给我们维护的。另外,我们用的第三方线程库,是需要加载到内存里的!而且是加载到共享区中!那么,在 pthread 库里面,每个创建好的线程,它就要为该线程在库里面开辟一段空间,用来充当新线程的栈!也就是说,新线程的栈是在共享区当中的!

那么,线程的概念是库给我们维护的,也就是说线程库要维护线程的概念,不需要维护线程的执行流。也就是,线程库中的线程在底层中对应的其实是轻量级进程的执行流,但是线程相关的属性等字段,必须需要库来维护!所以线程库注定了要维护多个线程属性的集合,所以线程库需要先描述,再组织管理这些线程!如下图:

在这里插入图片描述

所以,我们每创建一个线程,在线程库中就要为我们创建线程库级别的线程,我们把它叫做线程控制块。所以这个线程控制块我们就可以理解成 tcb,那么对于每一个 tcb 在库中可以理解成用数组的方式进行管理维护。所以为了让我们快速找到在共享库中的每一个 tcb,我们把每一个 tcb 在内存中的起始地址称为线程的 tid,即线程的 id

(2)线程栈

每一个线程在运行时,一定要有自己独立的栈结构,因为每一个线程都要有自己的调用链,也就是说每一个线程都要有自己调用链所对应的栈帧结构。这个栈结构会保存任何一个执行流在运行过程中的所有临时变量。其中,主线程用地址空间提供的栈结构即可,而新线程则是首先在库中创建一个线程控制块,这个控制块中有包含默认大小的空间,就是线程栈;然后库就要帮我们调用系统接口 clone() 帮我们创建执行流,最重要的是它会帮我们把线程栈传递给 clone() ,作为它的第二个参数!

在这里插入图片描述

所以,所有对应的非主线程的栈都在库中进行维护,即在共享区中维护,具体来说,是在 pthread 库中 tid 指向的线程控制块中!

我们可以写代码验证一下每一个线程都有自己独立的栈,代码链接:验证独立栈.

结果如下,test_stack 是三个线程里的临时变量,它们的地址都不一样:

在这里插入图片描述

同时我们也可以验证,全局变量是可以被所有线程同时看到并访问的。

其实线程和线程之间,几乎没有秘密,虽然它们是独立的栈,但是线程上的数据也是可以被其它线程访问到的。

(3)线程局部存储

我们知道,全局变量是可以被所有线程访问的,但是假设我们的线程想要一个私有的全局变量呢?我们可以在一个全局变量前加上 __thread,如下:

				__thread int g_val = 100;

接下来我们使用上面的代码,设置这样一个全局变量,并打印它的信息出来观察:

在这里插入图片描述

我们发现,每一个线程中的 g_val 的地址都是不一样的!而且对 g_val 运算的时候,它们互不干扰!所以这个 g_val 加上 __thread,就变成了线程的全局变量!其实 __thread 不是 C/C++ 提供的,而是一个编译选项。我们发现,打印出来的地址非常大,因为它是在堆栈之间的地址!它是位于线程控制块的线程局部存储区域!

注意,线程局部存储只能定义内置类型,不能定义自定义类型!

3. 分离线程

  • 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。

  • 如果不关心线程的返回值,join 是一种负担,因为主线程需要等待其它线程,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源,这就叫做线程的分离,可以用如下接口:

      			int pthread_detach(pthread_t thread);
    

在这里插入图片描述

其中参数就是线程的 tid.

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

				pthread_detach(pthread_self());