目录
一、什么是线程
1.1 线程的概念
我们先来看看线程的概念和特点:
● 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
● 一切进程至少都有一个执行线程
● 线程的执行粒度比进程更细,调度成本更低
● 线程在进程内部运行,本质是在进程地址空间内运行
● 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
● 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
● 内核观点:线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体
这么多概念也太抽象了,下面来从进程开始一步步引入:
上图就是我们常说的进程(不熟悉的同学可以看到这里:【Linux】进程概念(上)【Linux】进程地址空间),我们每一次创建进程时都要重新创建PCB(task_struct)、进程地址空间和对应映射的页表
那如果我们只创建一个PCB(task_struct)呢?让这个PCB指向现有的进程地址空间会怎么样?
如下:
💡这样子只创建PCB的方式,不再另外创建进程地址空间和对应映射的页表,这样子每个单独的PCB就是我们所说的线程!
其中每个PCB只指向代码区中代码的一部分,CPU指向哪个PCB,就执行对应代码区中的部分代码,这样子如果是CPU是多核的话,同一个程序的不同代码区间就可以同时运行了
那如何理解我们之前说讲述的进程?我们来看到下图:
💡我们将一个程序对应的所有PCB、进程地址空间、页表和所包含的物理内存称之为一个进程;而进程中一个个不同的PCB就是线程了(由于线程指向的执行的代码区有差异,我们可以将其称为一个个不同的执行流)。所以在往期的Linux博客中,我们所讲述的进程可以看做为只有一个PCB的进程
那如何理解线程比进程调度成本更低呢?仅仅是少创建了虚拟地址空间和页表吗?
💡线程比进程调度成本更低的原因不仅仅只少了创建虚拟地址空间和页表,在CPU中还存在着cache,我们每次让CPU切换不同的PCB时,如果切换过的PCB指向的进程地址空间发生了变化,CPU也会重置cache中的所保存的数据(cache是计算机系统中用于临时存储数据的一种高速存储器。其目的是在需要访问数据时能够更快地获取到数据,提高系统的性能和响应速度。缓存通常存储最近使用过的数据,以便在未来的访问中能够更快地获取到这些数据,而不必每次都访问较慢的原始数据存储位置)
那为什么说线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体呢?
💡因为CPU只需要切换指向的PCB就可以执行不同的代码,不必去关心其他的内容;在OS下资源的分配是按进程来的,不同的进程会分配不一样的进程地址空间、页表和内存
那所有的OS都是只向进程中添加一个个PCB来实现线程的吗?
💡关于线程的实现在不同的OS下是不一样的。在Windows操作系统下,为了管理线程有专门描述线程的结构体叫做TCB,为了管理这些TCB,Windows还专门设计了线程所对应的调度算法等等,所以在Windows下是有真线程的;但是在Linux下,设计者发现线程和进程的描述有极高的相似性,所以干脆复用进程的一系列方案来模拟线程的实现,所以在Linux下还是用PCB来描述线程,并且调度算法还是复用进程的,这样子只需要区分在进程内调度还是在不同的进程之间调度,所以Linux下没有真正意义上的线程,不过这样的设计更简单,所带来的优点便是更好维护、效率更高、也更安全(这就是为什么Linux系统可以不间断运行的原因所在)
由于Linux的特殊设计,CPU调用PCB执行代码时是分不清执行的是一个只有一个线程的进程,还是一个进程中的线程,所以在Linux下统一将CPU所调度的PCB所指向的执行流称为轻量级进程(Lightweight Process)
1.2 线程的优点
综上所述,我们可以总结出线程的优点:
● 创建一个新线程的代价要比创建一个新进程小得多
● 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
● 线程占用的资源要比进程少很多
● 能充分利用多处理器的可并行数量
● 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
● 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
● I/O密集型应用,为了提高性能,将I/O操作重叠;线程可以同时等待不同的I/O操作
我们来对最后两个优点来分析一下:
计算密集型应用指的是加密解密,文件压缩和解压等等与算法有关的功能,那在这个环境下是线程越多越好吗?
💡并不是,计算密集型的环境下进程/线程的数量和CPU的个数/核数密切相关;如果进程/线程的数量<CPU的个数/核数,那就会造成会有未利用CPU/核;反之,将会造成还有的进程/线程在等待CPU资源此时就需要通过调度来维护运行,但是调度本身也是需要资源的,会降低整体的运行效率
I/O密集型应用指的是下载、上传等等的IO操作,那在这个环境下是线程越多越好吗?
💡也不是,在该环境下我们要具体情况具体分析;因为IO操作可能会不能立马申请到资源,此时有进程/线程在等待IO资源时,我们可以调度其他进程/线程来进行IO,通过轮流调度的方式来提高运行效率。所以在这种情况下进程/线程的数量要适当的大于CPU的个数/核数
1.3 线程的缺点
● 性能损失(可避免)
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。但是造成该缺点的主要原因是线程的创建/使用并不合理所造成的,所以合理的使用线程是可以避免的
● 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的(进程中只要单个线程出现崩溃会导致整个进程的结束)
● 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响(因为执行流看到的资源是通过地址空间看到的,多个线程看到的是同一个地址空间,所以线程可能会共享进程的大部分资源!)
● 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
我们下面对健壮性降低、缺乏访问控制的缺点进行演示(对于线程的控制在下文会详细讲解,这里只需要看结果即可):
健壮性降低:
#include<iostream>
#include<pthread.h>
#include<unistd.h>
void* threadRun1(void *args)
{
while(1)
{
sleep(1);
std::cout<<"t1 thread"<<std::endl;
}
}
void* threadRun2(void *args)
{
char* p="hello world";
while(1)
{
sleep(3);
std::cout<<"t2 thread"<<std::endl;
*p='H';
}
}
int main()
{
pthread_t t1,t2;
pthread_create(&t1,nullptr,threadRun1,nullptr);//线程1正常运行
pthread_create(&t2,nullptr,threadRun2,nullptr);//线程2崩溃
while(1)//主线程正常运行
{
sleep(1);
std::cout<<"main thread"<<std::endl;
}
return 0;
}
运行结果:
我们可以看到线程2崩溃了,整个进程也崩溃了,可以从信号的角度解释一下:
线程2修改常量区的数据,页表转换的时候,MMU识别写入权限,没有验证通过,接着OS发8信号给当前进程造成结束
缺乏访问控制:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
int val = 0;
void *threadRun1(void *args)
{
while (1)
{
sleep(1);
std::cout << "t1 thread ,val:" << val << ",&val:" << &val << std::endl;
}
}
void *threadRun2(void *args)
{
while (1)
{
sleep(1);
std::cout << "t2 thread ,val:" << val++ << ",&val:" << &val << std::endl;
}
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, threadRun1, nullptr); // 线程1打印val值
pthread_create(&t2, nullptr, threadRun2, nullptr); // 线程2打印val值并++
while (1) // 主线程打印val值
{
sleep(1);
std::cout << "t2 thread ,val:" << val++ << ",&val:" << &val << std::endl;
}
return 0;
}
运行结果:
我们可以看到主线程、线程1、线程2看到的数据是一样的,并不会像进程一样进行写实拷贝,这也就意味着线程之间的通信是非常容易的!
1.4 线程的特性
线程共享进程数据,但也拥有自己的一部分数据::
● 线程ID
● 一组寄存器
● 栈
● errno
● 信号屏蔽字
● 调度优先级
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
● 文件描述符表
● 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
● 当前工作目录
● 用户id和组id
进程和线程的关系如下图:
二、线程的控制
2.1 pthread库
在操作系统视角看来:Linux下没有真正意义的线程,而是用进程模拟的线程(LWP),所以Linux不会提供直接创建线程的系统调用,最多会给我们最多提供创建轻量级进程的接口
但是在用户视角看来:是操作系统就应该有线程的操作接口,不管OS底层到底是怎么实现的,都应该有线程的概念
为了满足用户对Linux系统的线程操作需要,诞生了一个用户级线程库——pthread库
该库将Linux接口封装,给用户提供进行线程控制的接口,被称为原生线程库
所以在我们下面实际上手控制线程时,gcc/g++编译器都要带上-l pthread选项编译来使用pthread库
2.2 线程的创建
2.2.1 pthread_create
我们使用pthread_create函数来创建线程:
来看看参数:
● thread:输出型参数,用来返回线程ID
●attr:设置线程的属性,attr为NULL表示使用默认属性
●start_routine:函数指针(指向一个返回值为void*参数为void*的函数),传入线程启动后要执行的函数地址
● arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
我们来使用一下看看:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
void *threadRun(void *args)
{
while (1)
{
sleep(1);
std::cout << "new thread runing..." << std::endl;
}
}
int main()
{
pthread_t t1;
pthread_create(&t1, nullptr, threadRun, nullptr); // 线程1
while (1)
{
sleep(1);
std::cout << "main thread ,new thread:" << t1 << std::endl;
}
return 0;
}
运行结果:
咦?这里的创建的线程id怎么会这么大?
我们来看看线程的实际的id是多少:
2.2.1.1 ps - aL
在Linux中我们可以使用ps指令跟上-aL选项来查看所有正在运行的线程:
其中LWP就是线程id,我们可以看到主线程的LWP是就是其进程PID
但是LWP和程序里的线程id怎么会有这么大的差距呢?
就好比我们在学校的学号和在岗位上的工号一样,它们是可以不想同的
但是至于程序中的线程id为什么这么大,我们会在下文中仔细讲解
最后我们来演示一下的创建一批线程的做法:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#define NUM 10
void *threadRun(void *args)
{
char *name = (char *)args;
while (1)
{
sleep(1);
std::cout << "new thread runing , thread name:" << name << std::endl;
}
delete name;
}
int main()
{
pthread_t t[NUM];
for (int i = 0; i < NUM; i++)//创建一批线程
{
//将每个线程传入独有的名字
char *thread_name = new char[32];//使用堆来存储名字
sprintf(thread_name, "thread%d", i + 1);
pthread_create(t + 1, nullptr, threadRun, thread_name);
}
while (1)
{
sleep(1);
std::cout << "main thread" << std::endl;
}
return 0;
}
运行效果:
从上面的例子我们也可以看出,并不是越先创建的线程越先被调度,具体要取决于调度器的调度方法
2.3 线程的等待
我们来看到下面的代码:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#define NUM 10
void *threadRun(void *args)
{
char *name = (char *)args;
while (1)
{
sleep(1);
std::cout << "new thread runing , thread name:" << name << std::endl;
}
delete name;
}
int main()
{
pthread_t t[NUM];
for (int i = 0; i < NUM; i++)//创建一批线程
{
//将每个线程传入独有的名字
char *thread_name = new char[32];
sprintf(thread_name, "thread%d", i + 1);
pthread_create(t + 1, nullptr, threadRun, thread_name);
}
sleep(2);//2秒后主线程退出
return 0;
}
我们可以看到进程中只要主线程退出了,其他的线程也会一并退出
所以在多线程下主线程并不能随意退出,下面就有了线程的等待函数:
2.3.1 pthread_join
pthread_join
函数的参数:
●
thread
参数是目标线程的标识符●
value_ptr
是一个指向指针的指针,用于存储目标线程的返回值
pthread_join
函数的返回值是整型,用于表示函数的执行结果。其可能的返回值包括:
● 返回值为 0:表示函数调用成功,目标线程已经成功结束,并且返回值已经被存储在
value_ptr
所指向的位置中。● 返回值为错误码:表示函数调用出现了错误,无法等待目标线程结束。这时需要根据具体的错误码进行处理,并查看对应的错误信息。
这个函数的具体使用,我们在下文中进行讲解
2.4 线程的退出
线程的退出有两种方式,一种是线程函数执行完毕(return),另一种是调用pthread_exit函数
2.4.1 pthread_exit
●
value_ptr
参数用于指定线程的返回值,可以是任意类型的指针
2.5 线程控制的实用举例
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <ctime>
#define NUM 10
using namespace std;
enum
{
OK = 0,
ERROR
}; //
class ThreadData // 自定义线程的属性
{
public:
ThreadData(const string &name, int id, time_t createTime, int top)
: _name(name), _id(id), _createTime((uint64_t)createTime), _status(OK), _top(top), _result(0)
{
}
~ThreadData()
{
}
public:
// 输入数据
string _name; // 线程名
int _id; // 线程id
uint64_t _createTime; // 线程被创建时的时间戳
// 返回的数据
int _status; // 线程退出状态
int _top; // 计算区间
int _result; // 线程计算结果
};
void *thread_run(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args); // static_cast<ThreadData *>是指针类型强转
cout << td->_name << " runing , thread id:" << td->_id << ", create time:" << td->_createTime << endl;
int ret = 0;
for (int i = 1; i <= td->_top; i++) // 计算1-top区间所有整数和
{
ret += i;
}
td->_result = ret;
td->_status = OK;
std::cout << td->_name << " calculate done" << std::endl;
pthread_exit(td);
// return td;//使用return返回指针也可与pthread_exit(td)等效
}
int main()
{
pthread_t tids[NUM];
for (int i = 0; i < NUM; i++)
{
char tname[64];
snprintf(tname, 64, "thread-%d", i + 1); // 为子线程创建各自的名字
ThreadData *td = new ThreadData(tname, i + 1, time(nullptr), 100 + 8 * i); // 传入给子线程的数据
pthread_create(tids + i, nullptr, thread_run, td); // 子线程拿到td指针指向的数据,并进入到thread_run函数中运行
sleep(1);
}
void *ret = nullptr; // 此指针接受子线程返回的指针,以此拿到计算结果
for (int i = 0; i < NUM; i++) // 主线程回收子线程
{
if (pthread_join(tids[i], &ret) != 0) // 如果要回收的子线程还没结束主线程会进入阻塞
{
std::cerr << "pthread_join error" << std::endl;
}
ThreadData *td = static_cast<ThreadData *>(ret);
if (td->_status == OK)
{
std::cout << td->_name << " calculate ret:" << td->_result << ",region:[0," << td->_top << "]" << std::endl;
}
delete td;
}
cout << "all thread quit..." << endl;
return 0;
}
运行结果:
2.6 线程的取消
我们可以让主线程直接终止正在运行的子进程,此时会用到pthread_cancel函数:
2.6.1 pthread_cancel
thread
参数是目标线程的标识符
pthread_cancel
函数的返回值为 0,表示成功发送取消请求;非零值表示发送取消请求失败。需要注意的是,pthread_cancel
函数只是发送一个取消请求,并不等待目标线程立即取消,目标线程可以在取消请求到达后自行决定是否允许被取消
注意:线程如果被pthread_cancel取消后,返回值是/PTHREAD_CANCELED
#define PTHREAD_CANCELED ((void *) -1)
下面是实例演示:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
void *threadRun(void *args)
{
int n = 5;
while (n--)
{
cout << static_cast<const char *>(args) << ":" << n << endl;
sleep(1);
}
return (void *)0;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRun, (void *)"thread-1");
sleep(3);
pthread_cancel(tid);// PTHREAD_CANCELED 定义:#define PTHREAD_CANCELED ((void *) -1)
void *ret = nullptr;
pthread_join(tid, &ret);
cout << "thread-1 quit,return:" << (int64_t)ret << endl;
return 0;
}
运行结果:
2.7 pthread_self
我们可以使用pthread_self函数来获取当前线程的id:
使用举例:
#include <iostream>
#include <pthread.h>
using namespace std;
void *threadRun(void *args)
{
cout << static_cast<const char *>(args) << "id:" << pthread_self() << endl;
return (void *)0;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRun, (void *)"thread-1");
void *ret = nullptr;
pthread_join(tid, &ret);
cout << "thread-1 quit,return:" << (int64_t)ret << ",id:" << tid << endl;
return 0;
}
运行结果:
2.8 线程的分离
默认情况下,新创建的线程是需要join的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。 如果不关心线程的返回值,或者主线程也有自己的事情要做(不想进入阻塞状态),这时join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
下面我们介绍线程分离函数pthread_detach:
2.8.1 pthread_detach
thread
参数是要设置为分离状态的目标线程的标识符
函数返回值为 0 表示成功设置线程为分离状态,非零值表示设置失败
需要注意的是:在分离状态下的线程,其资源会被自动回收,无需其他线程调用pthread_join
函数来等待它的结束。 如果使用pthread_join
函数来回收分离状态下的线程,会发生报错
下面是简单的实例演示:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
void *threadRun(void *args)
{
int n = 3;
while (n--)
{
cout << static_cast<const char *>(args) << " running..." << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRun, (void *)"thread-1");
int ret = pthread_detach(tid);
if (ret == 0)
{
printf("Thread detached successfully.\n");
}
else
{
printf("Failed to detach thread.\n");
}
sleep(5);
return 0;
}
运行结果:
三、对于线程库的分析
在上文中我们可以知道我们使用pthread库来对Linux中的轻量级进程进行管理
那我们下面来看看其具体是怎么管理的:
3.1 pthread_t
我们可以从上面看到,Linux中的轻量级线程被pthread库组织成了一个个的数据结构,以类似于数组的形式聚合着存储在一起
而我们平时所使用的线程pthread_t类型的标识符,就是管理该轻量级进程的结构体的起始地址!
所以现在我们可以理解为什么pthread_t类型的标识符数据都这么大了
下面我们将pthread_t类型都转化为16进制地址空间的形式来看看:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string>
using namespace std;
string hexAddr(pthread_t tid)
{
char buffer[64];
snprintf(buffer, sizeof(buffer), "0x%x", tid);
return buffer;
}
void *threadRun(void *args)
{
cout << static_cast<const char *>(args) << " :" << hexAddr(pthread_self()) << endl;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRun, (void *)"thread-1");
int ret = pthread_detach(tid);
sleep(1);
cout << "main thread:" << hexAddr(pthread_self()) << endl;
return 0;
}
运行结果:
3.2 线程栈
每个被创建的子线程也都有自己的独有的数据,该数据会被pthread库保存在共享区的线程所在结构体中的线程栈之中
那主线程呢?它的数据不会被pthread库所管理,就保存在系统中的地址空间的栈结构中
我们最后再来看看我们使用的pthread_create底层调用的系统接口clone:
可以看到底层再创建一个轻量级进程的接口是十分复杂的,pthread_create的封装大大简化了我们学习的成本
四、编程语言中的多线程
在C++11中也有着线程的接口,再后面的C++专栏中我们会具体进行详细的讲解,下面我们先使用一下C++中的线程接口来验证一个问题:
#include <iostream>
#include <thread>
#include <unistd.h>
using namespace std;
void run1()
{
cout << "thread-1 running" << endl;
}
void run2()
{
cout << "thread-2 running" << endl;
}
void run3()
{
cout << "thread-3 running" << endl;
}
int main()
{
// 创建线程
thread t1(run1);
sleep(1);
thread t2(run2);
sleep(1);
thread t3(run3);
sleep(1);
// 回收线程
t1.join();
t2.join();
t3.join();
return 0;
}
我们可以看到上述代码在运行的过程中报错了,原因是在编译时并没有引入pthread库
下面我们引入pthread库来试试看:
这次运行成功了
从上面这个例子中我们可以看出即便是使用C++中的线程接口,其底层调用的还是pthread库中的接口,所以不管是任何语言上的线程接口,其底层都绕不开操作系统提供的原始接口!(和我们在文件系统中讲述的C语言文件接口是一个道理)
那我们在写代码时到底是使用语言所提供的接口还是使用操作系统所提供的原生接口好呢?
💡如果是跨平台的情况下,推荐使用语言级别的接口,这样子我们用同一个语言写出来的代码在其他平台上也可以正常运行;如果确定了使用场景只在一个平台下,推荐使用操作系统所提供的原生接口,这样可以减少跳转,提高运行效率