Linux多线程线程控制
目录
1.线程知识补充
1.1 线程私有资源
1.2 线程共享资源
1.3 原生线程库
2、线程控制接口
2.1 线程创建
2.1.1 一批线程
2.2 线程等待
2.3 线程终止
2.4 线程实战
2.5 其他接口
2.5.1 关闭线程pthread_cancel
2.5.2 获取线程 ID pthread_self
2.5.3 线pthread_detach
3. 深入理解线程
3.1 理解线程库及线程 ID
3.2 理解线程独立栈
3.3 理解线程局部存储
🌇前言
线程是进程内的基本执行单位,作为 CPU 执行的基本单位,线程的控制与任务执行效率息息相关。合理地进行线程管理,能大大提升程序的执行效率,掌握线程的基本操作至关重要。
🏙️正文
1.线程知识补充
在深入讨论线程控制接口之前,我们首先需要补充一些关于线程的基础知识。Linux
中没有真线程,只有复用 PCB
设计思想的 TCB
结构
1.1 线程私有资源
在 Linux 的多线程实现中,线程本质上是轻量级进程(LWP),即通过复用 PCB 设计的 TCB 结构来模拟线程。因此,尽管 Linux 系统中的多个线程共享同一进程的地址空间,但每个线程仍然需要一定的独立性和资源。
线程私有资源具体包括:
-
线程 ID:线程的唯一标识符,由内核管理
-
寄存器:每个线程的上下文信息,如寄存器,线程切换时需要保存这些信息
-
独立栈:每个线程都有独立的栈空间,用于存储局部变量和执行上下文
-
错误码(errno):线程异常退出时,通过错误码反馈信息
-
信号屏蔽字:各个线程对于信号的屏蔽字设置不同,确保每个线程能根据需要对信号做出响应
-
调度优先级:线程也需要被调度,调度算法根据优先级来合理分配执行时间
其中,寄存器和独立栈是线程最关键的私有资源,它们保障了线程切换的独立性以及运行时的稳定性。
1.2 线程共享资源
除了线程的私有资源,多线程还会共享进程的部分资源。线程共享资源不需要额外的开销,并能在各个线程间随时访问。
共享的定义:不需要太多的额外成本,就可以实现随时访问资源
基于 多线程看到的是同一块进程地址空间,理论上 凡是在进程地址空间中出现的资源,多线程都是可以看到的
但实际上为了确保线程调度、运行时的独立性,只能共享部分资源
在 Linux 中,共享资源包括:
-
共享区、全局数据区、字符常量区、代码区:这些区域是进程中天然支持共享的资源。
-
文件描述符表:在多线程中进行 I/O 操作时,无需每个线程都重新打开文件,文件描述符表在多个线程间共享。
-
信号处理方式:所有线程共同构成一个整体,信号处理必须统一。
-
当前工作目录:所有线程共享进程的工作目录。
-
用户 ID 和组 ID:进程属于特定的用户和组,线程也继承这些身份。
文件描述符表是多线程共享资源中最重要的部分,它确保了多线程 I/O 操作的高效性和协作性。
1.3 原生线程库
当我们编译多线程相关代码时,通常需要添加 -lpthread
参数,确保能够使用 pthread 原生线程库。
这是因为,在 Linux 中并没有真正意义上的线程,而是通过轻量级进程(LWP)来模拟线程的实现。Linux 系统并不会直接提供线程控制接口,而是通过封装轻量级进程相关操作,提供了线程控制的接口。
为了使用户能够方便地操作线程,Linux 提供了 pthread
库,这是一个标准的线程库,也是个第三方库,被存放在了系统及库路径下。封装了操作系统底层的轻量级进程控制接口。用户只需在编译时添加 -lpthread
参数(告诉库名),即可正常使用线程相关接口。
计算机哲学的体现:通过增加一层软件抽象来简化复杂度,解决操作系统对线程支持的不足。
在 Linux
中,封装轻量级进程操作相关接口的库称为 pthread
库,即 原生线程库,这个库文件是所有 Linux
系统都必须预载的,用户使用多线程控制相关接口时,只需要指明使用 -lpthread
库,即可正常使用多线程控制相关接口
2、线程控制接口
2.1 线程创建
要想控制线程,得先创建线程。对于原生线程库来说,创建线程使用的是 pthread_create
这个接口。
#include <pthread.h>int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
参数详解:
-
参数1 pthread_t:* 线程 ID,用于标识线程,本质上它是一个
unsigned long int
类型。
注:pthread_t*
表示这是一个输出型参数,用于在创建线程后获取新线程的 ID。 -
参数2 const pthread_attr_t:* 用于设置线程的属性,如优先级、状态、私有栈大小等。通常不需要特别处理,传递
nullptr
使用默认设置即可。 -
参数3 void *(start_routine) (void ): 这是一个非常重要的参数,类型为返回值为
void*
、参数也为void*
的函数指针。线程启动时,会自动回调此函数(类似于signal
函数中的参数2)。 -
参数4 void:* 显然,这个类型与回调函数中的参数类型相匹配,它是线程运行时传递给回调函数的参数。
返回值:
成功返回 0
,失败返回错误号。错误检查:
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回
pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小
理解了创建线程函数的各个参数后,就可以尝试创建一个线程了:
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;void* threadRun(void *arg) {while(true) {cout << "我是次线程,我正在运行..." << endl;sleep(1);}return nullptr;
}int main() {pthread_t t;pthread_create(&t, nullptr, threadRun, nullptr);while(true) {cout << "我是主线程 " << " 我创建了一个次线程 " << t << endl;sleep(1);}return 0;
}
这段代码非常简单。如果直接编译,可能会引发报错:
错误信息: 未定义 pthread_create
这个函数。
原因: 没有指定使用原生线程库,解决方法是在编译时添加 -lpthread
来链接线程库。
验证原生线程库是否存在:
你可以通过 ldd
命令查看已编译程序的库链接情况。例如,使用 ldd mythread
命令来查看是否成功链接到原生线程库。
ps -al
命令中的 LWP 是内核中线程的 ID,也可以看作是线程在进程中的唯一标识符。用户层的
pthread_t
是用户空间的线程标识符,表示线程控制块(TCB)的地址它与 LWP ID 是映射的。
程序运行时主线程和次线程的顺序如何?
线程的执行顺序由操作系统的调度器决定。多线程程序中的主线程和次线程执行顺序不确定,具体执行顺序依赖于调度器的调度策略。
2.1.1 一批线程
接下来我们演示如何创建一批线程。
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;#define NUM 5void* threadRun(void *name) {while(true) {cout << "我是次线程 " << (char*)name << endl;sleep(1);}return nullptr;
}int main() {pthread_t pt[NUM];for(int i = 0; i < NUM; i++) {// 注册新线程的信息char name[64];snprintf(name, sizeof(name), "thread-%d", i + 1);pthread_create(pt + i, nullptr, threadRun, name);}while(true) {cout << "我是主线程,我正在运行" << endl;sleep(1);}return 0;
}
细节:
在传递 pthread_create
的参数时,可以通过 起始地址+偏移量
的方式进行传递,这样每个线程就能接收到不同的参数信息。
预期结果: 打印出 thread-1
、thread-2
、thread-3
等。
实际结果: 五个次线程在运行,但打印出来的都是 thread-5
。
原因: char name[64]
是主线程栈区中的局部变量,多个线程共享这块空间,最后一次的覆盖导致每个线程都读取到相同的数据。
解决方法: 在堆区动态分配空间,为每个线程分配独立的内存区域,以确保信息的独立性。
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;#define NUM 5void* threadRun(void *name) {while(true) {cout << "我是次线程 " << (char*)name << endl;sleep(1);}delete[] (char*)name;return nullptr;
}int main() {pthread_t pt[NUM];for(int i = 0; i < NUM; i++) {// 注册新线程的信息char *name = new char[64];snprintf(name, 64, "thread-%d", i + 1);pthread_create(pt + i, nullptr, threadRun, name);}while(true) {cout << "我是主线程,我正在运行" << endl;sleep(1);}return 0;
}
通过这种方式,程序运行将符合预期,每个线程都会打印出自己独立的名称。
2.2 线程等待
线程等待 为什么需要线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
创建新的线程不会复用刚才退出线程的地址空间
主线程需要等待次线程。在原生线程库中,提供了 pthread_join
来等待一个线程的运行结束。
#include <pthread.h>int pthread_join(pthread_t thread, void **retval);
参数说明:
-
参数1 pthread_t: 待等待的线程 ID,本质上是一个无符号长整型类型。
-
参数2 void:这是一个输出型参数,用于获取次线程的退出结果。如果不关心返回值,可以传递
nullptr
。
返回值: 成功返回 0
,失败返回错误号。
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;#define NUM 5void* threadRun(void *name) {while(true) {cout << "我是次线程 " << (char*)name << endl;sleep(1);}delete[] (char*)name;return nullptr;
}int main() {pthread_t pt[NUM];for(int i = 0; i < NUM; i++) {// 注册新线程的信息char *name = new char[64];snprintf(name, 64, "thread-%d", i + 1);pthread_create(pt + i, nullptr, threadRun, name);}// 等待次线程运行结束for(int i = 0; i < NUM; i++) {int ret = pthread_join(pt[i], nullptr);if(ret != 0)cerr << "等待线程 " << pt[i] << " 失败!" << endl;}cout << "所有线程都退出了" << endl;return 0;
}
该程序确保了主线程在等待所有次线程结束后才会退出,确保了线程的正常结束。
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数
2.3 线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。(主线程return 0 要开始合理使用了)
2. 线程可以调用pthread_ exit终止自己。
3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_exit函数
功能:线程终止原型
void pthread_exit(void *value_ptr); 参数 value_ptr:value_ptr不要指向一个局部变量。返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了
pthread_join
中的 void **retval
是一个输出型参数,可以把一个 void *
指针的地址传递给 pthread_join
函数,当线程调用 pthread_exit
退出时,可以根据此地址对 retval
赋值,从而起到将退出信息返回给主线程的作用
为什么 pthread_join 中的参数2类型为 void**?
因为主线程和次线程此时并不在同一个栈帧中,要想远程修改值就得传地址,类似于 int -> &int,不过这里的 retval 类型是 void*
注意: 直接在 回调方法 中 return 退出信息,主线程中的 retval 也是可以得到信息的,因为类型都是 void*,彼此相互呼应
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;#define NUM 5void* threadRun(void *name)
{cout << "我是次线程 " << (char*)name << endl;sleep(1);delete[] (char*)name;pthread_exit((void*)"EXIT");// 直接return "EXIT" 也是可以的// return (void*)"EXIT";
}int main()
{pthread_t pt[NUM];for(int i = 0; i < NUM; i++){// 注册新线程的信息char *name = new char[64];snprintf(name, 64, "thread-%d", i + 1);pthread_create(pt + i, nullptr, threadRun, name);}// 等待次线程运行结束void *retval = nullptr;for(int i = 0; i < NUM; i++){int ret = pthread_join(pt[i], &retval);if(ret != 0)cerr << "等待线程 " << pt[i] << " 失败!" << endl;cout << "线程 " << pt[i] << " 等待成功,退出信息是 " << (const char*)retval << endl;}cout << "所有线程都退出了" << endl;return 0;
}
void*
非常之强大,可以指向任意类型的数据,甚至是一个对象
2.4 线程实战
无论是 pthread_create
还是 pthread_join
,它们的参数都有一个共同点:包含了一个 void*
类型的参数。这意味着我们可以通过传递对象指针给线程,并在其中执行某些特定任务处理。
我们首先创建一个线程信息类,用于计算从 0
到 N
的累加和。线程信息包括:
-
线程名字(包括 ID)
-
线程编号
-
线程创建时间
-
待计算的值
N
-
计算结果
-
状态
为了方便访问成员,权限设置为 public
。
// 线程信息类的状态
enum class Status
{OK = 0,ERROR
};// 线程信息类
class ThreadData
{
public:ThreadData(const string &name, int id, int n):_name(name), _id(id), _createTime(time(nullptr)), _n(n), _result(0), _status(Status::OK) {}public:string _name;int _id;time_t _createTime;int _n;int _result;Status _status;
};
此时就可以编写回调方法中的业务逻辑了:
void* threadRun(void *arg)
{ThreadData *td = static_cast<ThreadData*>(arg);// 业务处理for(int i = 0; i <= td->_n; i++)td->_result += i;// 如果业务处理过程中出现异常,可以设置 _status 为 ERRORcout << "线程 " << td->_name << " ID " << td->_id << " CreateTime " << td->_createTime << " 完成..." << endl;pthread_exit((void*)td);
}
主线程在创建线程及等待线程时,使用 ThreadData
对象。在后续修改业务逻辑时,只需修改类及回调方法,而不需要更改创建及等待逻辑,这有效地做到了逻辑解耦。
int main()
{pthread_t pt[NUM];for(int i = 0; i < NUM; i++){// 注册新线程的信息char name[64];snprintf(name, sizeof(name), "thread-%d", i + 1);// 创建对象ThreadData *td = new ThreadData(name, i, 100 * (10 + i));pthread_create(pt + i, nullptr, threadRun, td);sleep(1); // 尽量拉开线程创建时间}// 等待次线程运行结束void *retval = nullptr;for(int i = 0; i < NUM; i++){int ret = pthread_join(pt[i], &retval);if(ret != 0)cerr << "等待线程 " << pt[i] << " 失败!" << endl;ThreadData *td = static_cast<ThreadData*>(retval);if(td->_status == Status::OK)cout << "线程 " << pt[i] << " 计算 [0, " << td->_n << "] 的累加和结果为 " << td->_result << endl;delete td;}cout << "所有线程都退出了" << endl;return 0;
}
程序运行时,各个线程能够正确计算累加和。此示例展示了线程如何利用传递的对象指针进行任务处理。线程不仅可以用于计算,还可以扩展到其他领域,如网络传输、密集型计算、多路 I/O 等,关键在于修改业务逻辑。
2.5 其他接口
与多线程相关的还有一批简单但重要的接口,我们将一并介绍。
2.5.1 关闭线程pthread_cancel
线程不仅可以被创建,还可以被关闭。我们可以使用 pthread_cancel
来关闭已经创建并正在运行的线程。
#include <pthread.h> int pthread_cancel(pthread_t thread);
参数说明:
pthread_t thread
:表示被关闭的线程 ID。
返回值:
成功返回 0
,失败返回错误号。
该函数使用成功后,线程会被异常信号杀死,退出码为PTHREAD_CANCELED(-1),
pthread_join()函数会等待成功,回收资源一样成功,与pthread_detach.
detach是明确表明推出资源直接交由操作系统直接释放,一种不关心的状态,如果在用pthread_join等待的话,资源已经没有,所以会等待失败。
#include <iostream>
#include <string>
#include <ctime>
#include <unistd.h>
#include <pthread.h>using namespace std;void *threadRun(void *arg)
{const char *ps = static_cast<const char*>(arg);while(true){cout << "线程 " << ps << " 正在运行" << endl;sleep(1);}pthread_exit((void*)10);
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRun, (void*)"Hello Thread");// 3秒后关闭线程sleep(3);pthread_cancel(t);void *retval = nullptr;pthread_join(t, &retval);cout << "线程 " << t << " 已退出,退出信息为 " << (int64_t)retval << endl;return 0;
}
运行结果:
程序运行 3 秒后,可以看到退出信息为 -1
,这是因为 pthread_cancel
关闭的线程,其退出信息统一为 PTHREAD_CANCELED
即 -1
。
2.5.2 获取线程 ID pthread_self
线程 ID 是线程的唯一标识符,我们可以通过 pthread_self
获取当前线程的 ID。
#include <pthread.h> pthread_t pthread_self(void);
#include <iostream>
#include <string>
#include <ctime>
#include <unistd.h>
#include <pthread.h>using namespace std;void *threadRun(void *arg)
{cout << "当前次线程的ID为 " << pthread_self() << endl;return nullptr;
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRun, nullptr);pthread_join(t, nullptr);cout << "创建的次线程ID为 " << t << endl;return 0;
}
结果:
pthread_self
返回当前线程的 ID,而 t
显示的是主线程创建时的线程 ID。
2.5.3 线pthread_detach
父进程需要阻塞式等待子进程退出,主线程等待次线程时也是阻塞式等待。如果希望避免一直阻塞,我们可以使用线程分离。
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
注意: 如果线程失去了 joinable
属性,就无法被 join
,如果 join
就会报错
#include <pthread.h>
int pthread_detach(pthread_t thread);
参数说明:
pthread_t thread
:待分离的线程 ID。
返回值:
成功返回 0
,失败返回错误号。
#include <iostream>
#include <string>
#include <ctime>
#include <unistd.h>
#include <pthread.h>using namespace std;void *threadRun(void *arg)
{int n = 3;while(n){cout << "次线程 " << n-- << endl;sleep(1);}
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRun, nullptr);pthread_detach(t);int n = 5;while(n){cout << "主线程 " << n-- << endl;sleep(1);}return 0;
}
运行结果:
主线程和次线程并发执行,不需要担心次线程的退出会导致主线程阻塞。
3. 深入理解线程
3.1 理解线程库及线程 ID
在见识过原生线程库提供的一批便利接口后,我们不禁感叹库的强大。那么,这样一个强大的库究竟是如何工作的呢?
原生线程库本质上是一个存储在 /lib64
目录下的动态库,想要使用这个库,在编译时必须加上 -lpthread
来指定链接动态库。
程序运行时,原生线程库需要从磁盘加载到内存中,并通过进程地址空间映射到共享区供线程使用。
由于用户并不会直接操作轻量级进程的接口,因此需要借助第三方库进行封装,就像用户可能不了解操作系统提供的文件接口一样,而使用 C 语言封装的 FILE
库。
对于原生线程库来说,线程不仅仅是一个,而是多个。因此,在线程库中创建 TCB
(线程控制块)结构,类似于进程的 PCB
(进程控制块),其中存储线程的各种信息,例如线程独立栈信息等。
在内存中,整个线程库就像一个“数组”,每一块空间存储了 TCB
信息,每个 TCB
的起始地址就表示当前线程的 ID。由于地址是唯一的,因此线程 ID也是唯一的。LWP ID 是内核为每个线程分配的唯一标识符,线程在内核中的标识。
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>using namespace std;string toHex(pthread_t t)
{char id[64];snprintf(id, sizeof(id), "0x%x", t);return id;
}void *threadRun(void *arg)
{cout << "我是[次线程],我的ID是 " << toHex(pthread_self()) << endl;return (void*)0;
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRun, nullptr);pthread_join(t, nullptr);cout << "我是[主线程],我的ID是 " << toHex(pthread_self()) << endl;return 0;
}
我们之前打印 pthread_t
类型的线程 ID 时,实际打印的就是地址,不过它是以十进制显示的。我们可以通过一个函数将其转换为十六进制显示:
运行结果:
线程 ID 确实能转换为地址(虚拟进程地址空间上的地址)。
注意: 即便是 C++11 提供的 thread
线程库,在 Linux 平台中运行时,也需要带上 -lpthread
选项,因为它本质上是对原生线程库的封装。
3.2 理解线程独立栈
线程之间存在独立栈,保证它们在执行任务时不会相互干扰。我们可以通过以下代码来验证这一点:
多个线程使用同一个入口函数,并打印其中临时变量的地址:
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>using namespace std;string toHex(pthread_t t)
{char id[64];snprintf(id, sizeof(id), "0x%x", t);return id;
}void *threadRun(void *arg)
{int tmp = 0;cout << "thread " << toHex(pthread_self()) << " &tmp: " << &tmp << endl;return (void*)0;
}int main()
{pthread_t t[5];for(int i = 0; i < 5; i++){pthread_create(t + i, nullptr, threadRun, nullptr);sleep(1);}for(int i = 0; i < 5; i++)pthread_join(t[i], nullptr);return 0;
}
运行结果:
可以看到,五个线程打印出的临时变量地址不相同,证明每个线程都有独立的栈空间。
为什么 CPU 能够区分这些栈结构呢?
答案是:通过栈顶指针 ebp
和栈底指针 esp
来进行切换。ebp
和 esp
是 CPU 中两个非常重要的寄存器,即使是程序启动时,也需要借助这两个寄存器来为 main
函数开辟对应的栈区。
除了移动 esp
扩大栈区外,还可以同时移动 ebp
和 esp
来更改当前栈区。因此,在多线程中,栈区的切换是通过这两个寄存器来完成的。
3.3 理解线程局部存储
线程之间共享全局变量,操作全局变量时会影响其他线程:
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>using namespace std;int g_val = 100;string toHex(pthread_t t)
{char id[64];snprintf(id, sizeof(id), "0x%x", t);return id;
}void *threadRun(void *arg)
{cout << "thread: " << toHex(pthread_self()) << " g_val: " << ++g_val << " &g_val: " << &g_val << endl;return (void*)0;
}int main()
{pthread_t t[3];for(int i = 0; i < 3; i++){pthread_create(t + i, nullptr, threadRun, nullptr);sleep(1);}for(int i = 0; i < 3; i++)pthread_join(t[i], nullptr);return 0;
}
运行结果:
在三个线程的影响下,g_val
最终变成了 103
。
如果想让每个线程看到不同的全局变量,可以使用 __thread
修饰符,这样全局变量就不再存储在全局数据区,而是存储到每个线程的局部存储区中。
__thread int g_val = 100;
运行结果:
通过 __thread
修饰后,每个线程看到的 g_val
都是不同的,并且地址变大了。
解释:
“全局变量” 的地址变大是因为它不再存储在全局数据区,而是存储在线程的局部存储区中。线程的局部存储区位于共享区,并且共享区的地址天然大于全局数据区。