目录
一、线程的简单控制
1.多线程并行
2.线程结束
3.线程等待
(1)系统调用
(2)返回值
4.线程取消
5.线程分离
二、C++多线程小组件
三、线程库TCB
1.tid
2.局部储存
一、线程的简单控制
1.多线程并行
我们之前学过pthread_create可以创建线程,而且要使用原生线程库必须在编译时加上-lpthread。
我们用这个接口一次性创建五个进程。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
using namespace std;
#define NUM 5
void* start_routine(void* args)
{
string s;
s += "running->";
s += (char*)args;
while(1)
{
cout << "running->" << s;
sleep(1);
}
}
int main()
{
for(int i = 0; i<NUM; ++i)
{
pthread_t tid;
char buffer[64];
snprintf(buffer, sizeof(buffer), "new thread:%d\n",i+1);
pthread_create(&tid, nullptr, start_routine, (void*)buffer);
}
while(1)
{
cout << "main pthread running.\n";
sleep(1);
}
return 0;
}
我们运行三次程序:
我们可能会想,按照代码的逻辑,我们想要看到的结果是:
running->new thread:1
running->new thread:2
running->new thread:3
running->new thread:4
running->new thread:5
main pthread running.
可是为什么运行两次,main pthread running.打印的位置不断变化,而且后面的数字都是5。
首先解决第一个问题,main pthread running.打印的位置为什么不断变化?
这是因为线程的运行顺序是由调度器决定的,各个线程的执行进度不同,主线程不一定在最后才能打印main pthread running.
第二个问题,为什么后面的数字都是5?
这是因为,三次执行中主线程执行顺序靠前。主线程将5次创建线程的代码跑完了,每循环一次sprintf就会将上一次的buffer内容覆盖掉,循环5次,i+1变为5。此时,又因为包括主线程的6个线程共享同一个地址空间,所以它们同时能看到buffer。五个新线程通过参数传递的地址找到buffer并打印出来。
既然你说buffer作为缓冲区被覆盖掉了,那我们为每一个线程构建一个自己的缓冲区不就解决问题了?
这样的思想是没有问题的,我们可以使用一个pthread_data类管理线程,内部包含线程标识符tid和缓冲区buffer。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
using namespace std;
#define NUM 5
class pthread_data
{
public:
pthread_t tid;
char buffer[64];
};
void* start_routine(void* args)
{
pthread_data* p = (pthread_data*)args;
string s;
s += "running->";
s += p->buffer;
while(1)
{
cout << s;
sleep(1);
}
}
int main()
{
for(int i = 0; i<NUM; ++i)
{
pthread_data* pd = new pthread_data;
snprintf(pd->buffer, sizeof(pd->buffer), "new thread:%d\n",i+1);
pthread_create(&(pd->tid), nullptr, start_routine, (void*)pd);
}
while(1)
{
cout << "main pthread running.\n";
sleep(1);
}
return 0;
}
虽然确实1到5都出现了,但是因为我们控制不了线程运行的顺序,所以还是不能保证按数字顺序打印。
2.线程结束
线程执行的函数有一个void*返回值,我们返回空指针就能终止该线程。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
using namespace std;
#define NUM 5
class pthread_data
{
public:
pthread_t tid;
char buffer[64];
};
void* start_routine(void* args)
{
pthread_data* p = (pthread_data*)args;
string s;
s += "running->";
s += p->buffer;
int j = 1;
while(1)
{
cout << s;
sleep(1);
if(j++ == 5)
return nullptr;
}
}
int main()
{
for(int i = 0; i<NUM; ++i)
{
pthread_data* pd = new pthread_data;
snprintf(pd->buffer, sizeof(pd->buffer), "new thread:%d\n",i+1);
pthread_create(&(pd->tid), nullptr, start_routine, (void*)(pd->buffer));
}
while(1)
{
cout << "main pthread running.\n";
sleep(1);
}
return 0;
}
五个线程都能正常退出,最后只剩下主线程运行。
POSIX线程库也提供了一个接口用于结束线程
void pthread_exit(void* retval);
头文件:pthread.h
功能:终止当前线程。
参数:void* retval是线程的结束信息,设置为空指针就可以了。
void* start_routine(void* args)
{
pthread_data* p = (pthread_data*)args;
string s;
s += "running->";
s += p->buffer;
int j = 1;
while(1)
{
cout << s;
sleep(1);
if(j++ == 5)
pthread_exit(nullptr);
}
}
将return nullptr替换成该函数也能实现线程退出。
3.线程等待
(1)系统调用
和进程一样,线程在执行完毕时如果task_struct结构体不回收,就会导致内存泄漏(类似未被回收的僵尸进程)。所以我们需要使用pthread_join函数将线程加入等待队列,加入等待队列的线程会被回收,但是回收的现象我们是看不到的。
int pthread_join(pthread_t thread, void** retval);
头文件:pthread.h
功能:将标识符为tid的线程加入等待队列。
参数:pthread_t thread是需要等待的线程标识符,void** retval是线程结束返回的信息,是一个输出型参数。
返回值:等待成功返回0,等待失败返回错误码。
实际上,这个加入等待队列和我们之前的进程等待现象差不多,我们写一段代码,让五个新线程运行3秒终止,主线程负责回收五个新线程,观察一下结果。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;
#define NUM 5
class pthread_data
{
public:
pthread_t tid;
char buffer[64];
};
void* start_routine(void* args)
{
pthread_data* p = (pthread_data*)args;
string s;
s += "running->";
s += p->buffer;
int j = 1;
while(1)
{
cout << s;
sleep(1);
if(j++ == 3)
return nullptr;
}
}
int main()
{
vector<pthread_data*> vpd;
for(int i = 0; i<NUM; ++i)
{
pthread_data* pd = new pthread_data;
snprintf(pd->buffer, sizeof(pd->buffer), "new thread:%d\n",i+1);
pthread_create(&(pd->tid), nullptr, start_routine, (void*)pd);
vpd.push_back(pd);
}
for(auto& e : vpd)
{
pthread_join(e->tid, nullptr);
printf("wait success:%d\n", e->tid);
}
return 0;
}
运行结果:
主线程在执行至线程等待代码时,主线程并不会继续往下执行。这也证明了,线程等待是阻塞式等待。
(2)返回值
在之前我们写的start_routine线程函数有一个void*的返回值,它可以返回线程退出相关的信息。
比如说,下图的最后一行就可以以void*的格式返回1。
不过这里有个问题,它虽然能返回结束的信息,但是这个变量要怎么让主线程接收到呢?
pthread_join函数有一个输出型参数void** retval,我们在主线程内定义一个void*类型的ret指针变量。当一个线程被回收的时候,将ret传参,它的返回值就会被放进这个ret里。
又因为返回值类型为void*,如果只将ret传参,那么只会将ret的临时拷贝改变。所以参数必须为void**,这也是使用二级指针的原因。
我让每一个线程都返回1,运行代码。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;
#define NUM 5
class pthread_data
{
public:
pthread_t tid;
char buffer[64];
};
void* start_routine(void* args)
{
pthread_data* p = (pthread_data*)args;
string s;
s += "running->";
s += p->buffer;
int j = 1;
while(1)
{
cout << s;
sleep(1);
if(j++ == 3)
break;
}
return (void*)1;
}
int main()
{
vector<pthread_data*> vpd;
for(int i = 0; i<NUM; ++i)
{
pthread_data* pd = new pthread_data;
snprintf(pd->buffer, sizeof(pd->buffer), "new thread:%d\n",i+1);
pthread_create(&(pd->tid), nullptr, start_routine, (void*)pd);
vpd.push_back(pd);
}
for(auto& e : vpd)
{
void* ret = nullptr;
pthread_join(e->tid, &ret);
printf("wait success:%d,exit code:%d\n", e->tid, (long long)ret);
}
return 0;
}
运行结果:
把return换成只有一个参数void* retval的pthread_exit,它也可以将结果输出到变量中。
结果与上面的一致。
如果想要让每一个线程都返回各自的错误码,我们可以在pthread_data类中增加一个储存返回值的变量。
我下面就修改代码让每一个线程返回自己的编号。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;
#define NUM 5
class pthread_data
{
public:
pthread_t tid;
char buffer[64];
int num;
};
void* start_routine(void* args)
{
pthread_data* p = (pthread_data*)args;
string s;
s += "running->";
s += p->buffer;
int j = 1;
while(1)
{
cout << s;
sleep(1);
if(j++ == 3)
break;
}
//return (void*)1;
pthread_exit((void*)(p->num));
}
int main()
{
vector<pthread_data*> vpd;
for(int i = 0; i<NUM; ++i)
{
pthread_data* pd = new pthread_data;
snprintf(pd->buffer, sizeof(pd->buffer), "new thread:%d\n",i+1);
pd->num = i+1;
pthread_create(&(pd->tid), nullptr, start_routine, (void*)pd);
vpd.push_back(pd);
}
for(auto& e : vpd)
{
void* ret = nullptr;
pthread_join(e->tid, &ret);
printf("wait success:%d,exit code:%d\n", e->tid, (long long)ret);
}
return 0;
}
运行结果:
4.线程取消
线程取消也是终止线程的一种方式,可使用下面的系统调用。
int pthread_cancel(pthread_t thread);
头文件:pthread.h
功能:取消标识符为thread的线程。
参数:pthread_t thread是需要取消的线程标识符。
返回值:取消成功返回0,取消失败返回错误码。
我们创建10个线程,在中途取消前五个线程,查看具体的现象。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;
#define NUM 10
class pthread_data
{
public:
pthread_t tid;
char buffer[64];
int num;
};
void* start_routine(void* args)
{
pthread_data* p = (pthread_data*)args;
int j = 1;
while(1)
{
sleep(1);
if(j++ == 3)
break;
}
//return (void*)1;
pthread_exit((void*)(p->num));
}
int main()
{
vector<pthread_data*> vpd;
for(int i = 0; i<NUM; ++i)
{
pthread_data* pd = new pthread_data;
snprintf(pd->buffer, sizeof(pd->buffer), "new thread:%d\n",i+1);
pd->num = i+1;
pthread_create(&(pd->tid), nullptr, start_routine, (void*)pd);
vpd.push_back(pd);
}
for(int i = 0; i<NUM; ++i)
{
printf("new thread:%d,address:%p\n", i+1, vpd[i]);
}
for(int i = 0; i<vpd.size()/2; ++i)
{
pthread_cancel(vpd[i]->tid);
}
for(auto& e : vpd)
{
void* ret = nullptr;
pthread_join(e->tid, &ret);
printf("join success:%d,exit code:%d\n", e->tid, (long long)ret);
}
return 0;
}
运行结果:
对于取消的前五个线程,等待会直接成功,返回值是-1。
未被取消的后五个线程,仍然阻塞等待,等待成功后返回的是设定的线程编号。
如果一个线程是被取消结束的,退出码就是-1,是一个宏PTHREAD_CANCELED。
5.线程分离
默认情况下,在线程退出后,需要使用pthread_join将它加入等待队列,否则就会造成内存泄漏。
但是主线程只能阻塞式线程,阻塞时主线程只能干等着。而且我们有时根本不关心线程的返回值,那阻塞式等待就是巨大的负担。
那么,能不能模仿之前的轮询检测让主线程也继续干活吗?
可以,我们可以将需要释放的线程变为分离状态。我们将一个进程的所有线程做成一个组,如果将一个进程移出这个组,我们就说该线程处于分离状态。此时,主线程不用再关心该线程的状态,它会由系统自动释放。
我在这里也在说明一下,只要是让线程加入等待队列,那就必须要阻塞式等待;只要是分离状态,就不能再加入等待队列。所以说,可加入阻塞队列和分离状态是相互矛盾的,这也解释了为什么不能轮询非阻塞等待。
int pthread_detach(pthread_t thread);
头文件:pthread.h
功能:设置标识符为thread的线程分离状态。
参数:pthread_t thread是需要分离的线程标识符。
返回值:取消成功返回0,取消失败返回错误码。
这个函数既可以分离线程组内的其他线程,也可以分离自己,但分离自己就需要用到自己的线程标识符tid,线程自己的tid可以由下面的函数获取。
pthread_t pthread_self(void);
头文件:pthread.h
功能:获取线程自己的tid。
返回值:返回自己的tid。
我们创建一个新线程,让新进程在第一步就分离执行,观察主线程能否回收它。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
#include<string.h>
using namespace std;
#define NUM 10
void* start_routine(void* args)
{
pthread_detach(pthread_self());
string s = static_cast<const char*>(args);
for(int i = 0; i<5; ++i)
{
cout << s;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, (void*)"new thread running\n");
cout<<"main thread tid: 0x"<<(void*)pthread_self()<<endl;
int n = pthread_join(tid, nullptr);
cout << "error: " << n << ":" << strerror(n) << endl;
return 0;
}
线程只要分离,主线程就管不了它了,而且我们发现确实不能回收该分离的线程了,与预期效果一致。
我们把start_routine的前两个语句调换位置。让线程先去构造string对象,然后将其分离。
此时运行观察,我们发现主线程把新线程回收了。这又是怎么回事呢?
这是因为主线程优先运行,在新线程开始运行时,主线程已经在阻塞等待新线程了,这样设置分离也就没有意义了。
既然在新线程中分离线程并不保险,我们就将分离操作全部放在主线程中。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
#include<string.h>
using namespace std;
#define NUM 10
void* start_routine(void* args)
{
string s = static_cast<const char*>(args);
for(int i = 0; i<5; ++i)
{
cout << s;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, (void*)"new thread running\n");
//在主线程分离新线程
pthread_detach(tid);
cout<<"main thread tid: 0x"<<(void*)pthread_self()<<endl;
int n = pthread_join(tid, nullptr);
cout << "error: " << n << ":" << strerror(n) << endl;
return 0;
}
最终结果就不会出错了。
二、C++多线程小组件
既然我们也学习了一些系统调用了,我们可以试着写一个小组件。这个小组件将线程的创建、执行和等待等都封装起来。我们在程序中指定一个函数,让多个线程不断地执行该函数。
thread_handler.h
#include<pthread.h>
#include<assert.h>
#define NUM 64
class Thread;//前置声明
class Context
{
public:
Context()
:_this(nullptr)
,_args(nullptr)
{}
Thread* _this;//线程this指针
void* _args;//pthread_create需要传递的args
};
class Thread
{
typedef std::function<void*(void*)> func_t;
public:
//构造函数创建线程
Thread(func_t func, void* args, int number = 0)
:_func(func)
,_args(args)
{
//对线程进行规范化命名
char buffer[NUM];
snprintf(buffer, sizeof(buffer), "thread-%d", number);
_name = buffer;
//将线程信息保存到Context变量
Context* p = new Context;
p->_this = this;
p->_args = args;
int n = pthread_create(&_tid, nullptr, start_routine, (void*)p);
}
//运行函数
void* run(void* args)
{
return _func(args);
}
//线程执行start_routine,但是它如果是普通成员函数,则参数多了一个this指针
//将其变为static成员函数就能消除this指针
//但同时我们又需要执行线程内部成员_func函数,此时既不能访问this指针的内容,之前创建线程传递的void* args也传递不到
//我们再次构建一个类Context,将这些内容包含进去,将它们通过一个类指针传过去就好了
static void* start_routine(void* args)
{
Context* cp = static_cast<Context*>(args);
void* ret = cp->_this->run(cp->_args);
}
//将加入等待队列
void join()
{
int n = pthread_join(this->_tid, nullptr);
assert(n == 0);
}
private:
std::string _name;
pthread_t _tid;
func_t _func;
void* _args;
};
test.cc
#include<iostream>
#include<memory>
#include<unistd.h>
#include"thread_handler.h"
using namespace std;
void* handler(void* args)
{
string s = "new thread:";
s += static_cast<const char*>(args);
s += '\n';
while(1)
{
cout << s;
sleep(1);
}
}
int main()
{
unique_ptr<Thread> t1(new Thread(handler, (void*)"thread1", 1));
unique_ptr<Thread> t2(new Thread(handler, (void*)"thread2", 2));
unique_ptr<Thread> t3(new Thread(handler, (void*)"thread3", 3));
t1->join();
t2->join();
t3->join();
return 0;
}
可以看到三个线程在不停地执行我们的函数。
三、线程库TCB
1.tid
我们编写代码让新线程打印自己的 tid,主线程打印自己和新线程的tid。
#include<iostream>
#include<pthread.h>
#include<assert.h>
#include<unistd.h>
using namespace std;
void* start_routine(void* args)
{
string s = (char*)args;
while(1)
{
cout << s << " tid:0x" << pthread_self() << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, start_routine, (void*)"new pthread");
assert(n == 0);
while(1)
{
printf("main thread tid:0x%p,new thread tid:0x%p\n", pthread_self(), tid);
sleep(1);
}
return 0;
}
我们发现主线程和新线程打印的新线程tid相同,而且它们是一个地址。
我们一开始就强调,Linux内核中没有线程概念,只有轻量级进程的PCB,更没有TCB这样的数据结构。我们宏观看到的线程是系统的通过clone创造出的轻量级进程,只是这些轻量级进程共用了地址空间等资源。然后这些轻量级进程再通过POSIX原生线程库模拟出我们看到的线程。
既然原生线程库可以保证大量的线程同时工作,那么原生线程库中就必定有能管理这些线程的数据结构,换句话说,TCB结构一定在线程库中。
由于存储线程属性的TCB不在内核中,所以Linux的TCB也叫做用户级线程。
结论:Linux内核只负责调度执行流,用户关心的线程及其属性都由原生线程库维护。Linux的用户级线程和内核轻量级进程都保存着线程的属性,二者基本做到一一对应。
我们将视线转向地址空间:
原生线程库加载到内存后,页表会对虚拟地址空间和其内存物理地址建立映射关系。根据地址空间的分区,线程库会映射在虚拟地址空间中的共享区中,其中TCB等结构和数据当然也在共享区。
看下面的图片
我这里严谨一点:
由于局部储存和栈结构空间都只是在TCB存储指针,虽然这些都是TCB拥有的数据,但它们的内容肯定不会直接保存在TCB里,所以我就把它们和TCB分开了。
你要是认为全部的数据就是TCB也可以接受。
我们能得到以下结论:
- 共享区内的每个线程都有自己的TCB、局部储存和独立栈,它们由载入内存的原生线程库维护。
- 主线程的栈就是地址空间的栈区,而新线程的独立栈结构都在共享区,所以线程间才能有独立栈结构。
- tid是指针类型,TCB在地址空间中的虚拟地址就是tid的值。
2.局部储存
我们知道同一进程的线程相互共用地址空间和页表,所以它们都可以使用一个全局变量。
可如果新线程仍然想用这个变量名,但又不想影响其他线程。这时需要让这个全局变量在每个进程中独立使用,此时就可以使用线程的局部存储属性了。
不使用局部储存
主线程和新线程都用了同一个g_val变量。
在int g_val = 0;前面加上__thread就能将该变量独立出去。
#include<iostream>
#include<pthread.h>
#include<assert.h>
#include<unistd.h>
using namespace std;
__thread int g_val = 0;
void* start_routine(void* args)
{
string s = (char*)args;
while(1)
{
++g_val;
printf("new thread g_val:%d,address:%p\n", g_val, &g_val);
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, start_routine, (void*)"new pthread");
assert(n == 0);
while(1)
{
++g_val;
printf("main thread g_val:%d,address:%p\n", g_val, &g_val);
sleep(1);
}
return 0;
}
主线程和新线程使用的不再是同一个g_val变量,互相之间也不会受到影响。
结论:在全局变量或static变量前添加 __thread,可以让每个线程的TCB中都有一份独立的变量,不会互相影响。