写在前面
我们今天来看线程的知识点,这个博客的内容很多,主要就是为了我们后面的网络做铺垫,最关键的是相比较于进程而言,线程是更加优秀的,我们现在的计算机大多采用的就是线程.
线程
我之前谈过在创建子进程的时候,也就是fork的时候,我们可以通过条件判断来让父子进程执行不同的代码,也就是父子进程有不同的执行流,此时我们对特定的资源进行了划分.
但是我们知道创建一个进程要实例化一个task_struct,还要创建页表,虚拟地址空间…这些资源准备是在是太浪费时间了.于是程序员提出一个叫作线程(Thread)的概念,我们不创建所有的资源,只创建一个pcb,他和原本的进程共用一片空间.由于OS看待资源的时候是通过虚拟地址空间结合页表去看待的.我们通过某种方式去控制不同的PCB就可以了,此时我们把每一个PCB称之为一个线程.这就像原本有一个富豪,有很多产业,每天都要去工厂,去金融,去自己手下各种产业去转转,每天富豪很充实也很忙,年纪大了,有了很多的孩子,把家产分给自己的儿子,富豪自己也保留一个产业.原本每天串行化的去查看自己的产业,现在只需要关心自己手底下的就可以了.
上面谈的是Linux环境下线程的实现方法,这意味不同的平台对线程的实现和管理是不一样的.我们可以很容易的想到线程和进程的数量比是n:1的,既然进程都存在自己的数据结构,按道理说线程也是应该存在的,很多教材说线程被描述为tcp,这是很对的.但是我们不得不谈到一个理念问题,不同的系统对线程的实现是不一样的,如果是一个真系统,例如Windows,他就是真正的实现出了tcp,这样的设计者把线程和进程在执行流层面看作不一样的,各自不同.那么这就带来很大的维护成本,线程和进程之间的耦合关系变得非常的复杂.
Linux并未单独的为线程创建数据结构.它是这么想的,他认为这个世界上没有进程和线程在概念的区别.他认为为只有一个执行流,以同一的视角去看它们,无非线程比较轻罢了.所以Linux中是不存在真正意义上线程,Linux使用进程模拟线程的.或者说是用pcb模拟的.你说了这么多?也就是Linux没有tcb?不是的,有的,他就叫做pcb.大家不要固执的认为不他们不一样,只不过理念的不同罢了,Linux是世界上最聪明的一批工程师来搞的…认为分开来是非常麻烦.
由于Linux内核没有为线程准备独立的接口,但是它为我们准备自己的线程库,这是Linux自身带的库.以后关于线程的只需要库里就可以了.
进程重构
前面我们说了进程就是PCB,也就是内核数据结构 和进程对应的代码和数据.之前这么说只是为了更容易让我们理解一点,当然上面说的也是非常对的.只不过今天谈到了线程,这里重构一下.我们这里从内核视角给一个全新的定义.进程是承担分配资源的基本实体,他创建页表,链接物理内存,实例化task_struct,mmstruct这些都是为了申请资源.进程最大的意义不是被执行,是申请资源,进程是申请资源的基本单位.那么我们如何理解老的进程
- 内部有单个执行流的进程叫做 单执行流进程
- 内部有多个执行流的进程叫做 多执行流进程
线程VS进程
这里给大家举一个例子,在社会中假设家庭是分配资源的基本单位,而家里面的每一个人相互依存但有相互独立,我们家里面每一个人都在忙着自己的事情,例如父母勤劳工作,爷爷奶奶照顾好自己的身体,孩子好好学习,我们在忙着自己工作的最大目的也就是为了这个家庭过的更加的好,此时我们可以把每一个家庭成员看作一个线程.那么我们知道在家庭中有些东西是我们共同拥有的,例如冰箱,洗衣机等等.进程和线程也是如此,由于线程是由PCB模拟出来的.进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程 中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数) \
- 当前工作目录
- 用户id和组id
但是我们家里面每一个人也有自己的小秘密不想别人知道,例如我们平常写的日记就不想被别人看到,线程也是如此,他也存在属于自己的数据.
- 线程ID
- 一组寄存器
- 栈
- errno
- 信号屏蔽字
- 调度优先级
你刚才给我说了这么多,我们如何证明呢?我们这里直接上代码,后面都会谈到,我们先把线程创建起来玩玩.
#include <pthread.h>
#include <iostream>
#include <unistd.h>
using namespace std;
void *callback1(void *args)
{
while (true)
{
cout << "我是新线程 " << (char *)args << "... " << endl;
sleep(1);
}
}
void *callback2(void *args)
{
while (true)
{
cout << "我是新线程 " << (char *)args << "... " << endl;
sleep(1);
}
}
void *rout(void *args)
{
while (true)
{
cout << "我是新线程..." << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_create(&tid1, nullptr, callback1, (void *)"thread1"); // 创建线程
pthread_create(&tid2, nullptr, callback2, (void *)"thread2"); // 创建线程
while (true)
{
cout << "我是主线程..." << endl;
sleep(1);
}
pthread_join(tid1, nullptr); // 等待线程
pthread_join(tid2, nullptr); // 等待线程
return 0;
}
线程优点
线程相比较于 进程而言存在这很多的优点,毕竟它是接受前面的经验发展起来的.
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
我来解释一下什么是计算密集型,这个我们可以理解为解密和加密文件,IO密集型指的是我们IO频率很高.
线程缺点
- 性能损失 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型 线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的 同步和调度开销,而可用的资源不变。
- 健壮性降低 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了 不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 缺乏访问控制 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高 编写与调试一个多线程程序比单线程程序困难得多
线程控制
这里来看线程的控制,很简单,主要这些思想我们在进程那里都谈过,所以大家理解起来是非常容易的.不过这个之前,我们添加一点附加的知识.
页表
我们看一下下面的代码,在学C语言的时候我们就已经知道了字符串常量是不能被修改的,那么请问既然不能被修改,它是如何被初始化的呢?这里就涉及到页表的知识点.
int main()
{
const char* str = "hello";
*str = 'H';
return 0;
}
所有的一切都是和页表有关的,这里我给大家说一个观点,对于物理内存,任意一块空间都是可以读取和修改的,当我们加上了一个页表后,准确来说是当我们虚拟地址空间通过页表映射物理内存的时候,虚拟地址空间的字符常量区等等通过页表加上了某些限制使得我们对物理内存的更改变得不同.这里我想一下,那么页表是如何设计的呢?此时我们按照32位系统来和大家分析.
我们想如果是32位系统,对于每一个虚拟地址,也就是每一个字节我们都要给他映射到物理地址,也就是我们需要232个条目,每一个条目都应该有一个虚拟地址和物理地址的映射关系,我们保守假设这一个条目是8个字节.232个条目也就是32g的空间.想一想4g的空间这个是在是太大了,这样设计确实有点错.那么Linux是这么设计的吗?不是的.它的设计可以说是巧夺天公.
Linux是这样设计的,我们把32位的地址分开来算,我们把他分成10,10,12这个三个.首先我们拿第一个10个比特位来做一个索引,我们为他创建一个页表,其中左侧的就是这个10位索引,右侧的每一个条目都是一个指向页表的指针,这些指针指向的页表的左侧又是一个中间的10位,这又是一个索引.那么右侧的是什么呢?这里就要谈另外一个内容了.我们前面说了OS访问内存的基本但是是4kb,也就是一页.那么对于物理内存,我们是4g的空间.此时我们可以把物理内存划分成220个页,顺便我们也把这一个页称之为页帧.既然我们存在那么多的页,OS是不是要把它管理起来呢?是的.我们需要先描述在组织.
那么请问我们谈了这么多和页表的设计又有什么关系呢?要知道212就是4kb.我们第三个部分就是一个页的大小,12位就是在这个页中的业内偏移量.即使我们页表是这么设计的,那么还是应该存在220个条目,感觉你说的很对,这里确实有点大了.实际上,我们这些页表也不是一下就全部加载出来的,它是用的时候再去加载.
线程创建
我们开始学习线程的创建,我们后面用到的函数都是线程库里面别人帮助我们封装好的,先来看第一个接口.
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
先来和大家蒴一下这些个参数,第一个参数是线程id,其中pthread_t就是一个无符号整型,第二个参数是一个与线程性质的有关的参数,我们不谈,这里直接设置成空指针.第三个参数是一个函数指针,后面我们线程有关的代码都是在这函数中执行的,第四个参数我们这里可以理解为是一个线程的名字,这是我们手动传入的,这个参数会作为我们第三个指针指向的函数的参数出现,后面这个用处也是很大的,要知道指针可以指向很多东西.
void *callback(void *args)
{
while (true)
{
cout << "我是新线程 " << (char *)args << "... " << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, callback, (void *)"thread1");
while (1)
{
cout << "我是主线程... " << endl;
sleep(1);
}
return 0;
}
此时我们就创建了一个新线程,我们需要查看一下.
此时你就会发现我们只有一个进程,这作证了我们多个线程是共用一个进程的资源的,我们上面的指令是查看进程的,这里 换一下产看线程的代码.下面才是我们查看轻量级进程的指令.
我们可以很容易看到存在两个线程,这两个线程的pid是一样的,最关键的是我们发现了另外一个编号,叫做LWP,这个我们可以理解为是线程的编号,当线程的编号和我们的pid是一样的时候,这就是我们的主线程,也就是我们之前谈到的进程.
pthread_self
那么我们疑惑了,第一个参数的作用是是什么,总不能就是一i个简单的输出型参数吧,这里有两个层次的要谈,我们先说第一层.它是该线程的线程id,算是线程的标识吧.我们这里打印一下.
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, callback, (void *)"thread1");
while (1)
{
cout << "我是主线程... 新线程id是 " << tid << endl;
sleep(1);
}
return 0;
}
除了我们在主线程可以访问到线程的id,新线程也是可以访问到自己的id的,这里需要调用一个函数.
pthread_t pthread_self(void);
void *callback(void *args)
{
while (true)
{
cout << "我是新线程,线程id " << pthread_self() << "... " << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, callback, (void *)"thread1");
while (1)
{
cout << "我是主线程... 新线程id是 " << tid << endl;
sleep(1);
}
return 0;
}
线程等待
和进程一样,线程结束后我们也是需要等待的,如果不进行等待,就会出现和僵尸进程类似的东西,造成内存泄漏这一点是需要我们明白的.但是我看下面的的代码也没有显示出线程变成类似僵尸那样的状态啊,这是由于我们这个指令对于已经死去的线程不在进行展示了.
void *callback(void *args)
{
int cnt = 10;
while (cnt)
{
cout << "我是新线程 线程id" << (char *)args << " cnt " << cnt << endl;
sleep(1);
cnt--;
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, callback, (void *)"thread1");
pthread_create(&tid2, nullptr, callback, (void *)"thread1");
pthread_create(&tid3, nullptr, callback, (void *)"thread1");
while (1)
{
cout << "我是主线程... " << endl;
sleep(1);
}
return 0;
}
看一下线程等待函数,这个可以等待我们指定的线程
int pthread_join(pthread_t thread, void **retval);
关于参数,第一个就是我们要等待的线程id,第二个是一个二级指针,这里先暂时不谈,直接置为空,它的等待是阻塞式等待
void *callback(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "我是新线程 线程id" << (char *)args << " cnt " << cnt << endl;
sleep(1);
cnt--;
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, callback, (void *)"thread1");
pthread_create(&tid2, nullptr, callback, (void *)"thread1");
pthread_create(&tid3, nullptr, callback, (void *)"thread1");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
cout << "线程等待结束 " << endl;
sleep(5);
return 0;
}
线程分离
我们上面的线程等待是阻塞式的等待,也就是如果我们要是在等待某一个新线程,这个时候我们主线程就不能执行自己的任务,那么能不能不等待这个线程呢?可以的,此时我们就需要线程分离,我们可以把一个线程和主线程进行分离,不再关心他的运行情况,如果被分离的线程结束了,资源会被自动的回收.
int pthread_detach(pthread_t thread);
void *callback(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "我是新线程 线程id" << (char *)args << " cnt " << cnt << endl;
sleep(1);
cnt--;
}
}
int main()
{
pthread_t tid1;
pthread_create(&tid1, nullptr, callback, (void *)"thread1");
pthread_detach(tid1);
cout << "线程等待结束 " << endl;
sleep(10);
return 0;
}
这里还是不能说明我们线程已经分离.被分离的线程是不能被join的,否则就会报错,这句话我们可以验证一下.
void *callback(void *args)
{
pthread_detach(pthread_self());
cout << "线程分离" << endl;
int cnt = 50;
while (cnt)
{
cout << "我是新线程 线程id" << (char *)args << " cnt " << cnt << endl;
sleep(1);
cnt--;
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, callback, (void *)"thread 1");
pthread_create(&tid2, nullptr, callback, (void *)"thread 2");
pthread_create(&tid3, nullptr, callback, (void *)"thread 3");
int n = pthread_join(tid1, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid2, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid3, nullptr);
cout << n << ":" << strerror(n) << endl;
return 0;
}
我们很是疑惑,我们不是线程分离了吗?为何我看main主线程还在阻塞等待,这是由于我们再创建新线程的时候,新线程去执行自己的了,但是主线程还是需要向下执行的,这就是一旦主线程先碰到join,那么线程分离就没有效果了.那么是不是呢?,我们主线程等待一下.
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, callback, (void *)"thread 1");
pthread_create(&tid2, nullptr, callback, (void *)"thread 2");
pthread_create(&tid3, nullptr, callback, (void *)"thread 3");
sleep(1);
int n = pthread_join(tid1, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid2, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid3, nullptr);
cout << n << ":" << strerror(n) << endl;
return 0;
}
此时我们就知道我们想法是没有错的,也就是我们线程分离最好是在主线程进行分离,这样会极大的避免上面分离不成功的现象.
void *callback(void *args)
{
int cnt = 50;
while (cnt)
{
cout << "我是新线程 线程id" << (char *)args << " cnt " << cnt << endl;
sleep(1);
cnt--;
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, callback, (void *)"thread 1");
pthread_create(&tid2, nullptr, callback, (void *)"thread 2");
pthread_create(&tid3, nullptr, callback, (void *)"thread 3");
pthread_detach(tid1);
pthread_detach(tid2);
pthread_detach(tid3);
int n = pthread_join(tid1, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid2, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid3, nullptr);
cout << n << ":" << strerror(n) << endl;
return 0;
}
线程结束
我们知道进程结束只能有三种情况,这里就不列举了,那么线程呢?线程如果遇到异常,会怎么样?我们来一个测试
void *callback(void *args)
{
int cnt = 5;
while (cnt)
{
if (cnt == 2)
{
cout << "注意我要除零了" << endl;
int a = 10;
a /= 0;
}
cout << "我是新线程 线程id" << (char *)args << " cnt " << cnt << endl;
sleep(1);
cnt--;
}
pthread_exit((void *)10);
}
void handler(int signo)
{
cout << "我是一个进程 pid " << getpid() << ",刚刚获取了一个信号: " << signo << endl;
exit(1); // 这里捕捉完直接退出
}
int main()
{
signal(8, handler);
pthread_t tid1;
pthread_create(&tid1, nullptr, callback, (void *)"thread1");
void *ret = nullptr;
pthread_join(tid1, &ret);
cout << "线程等待结束 ret " << (long long)ret << endl;
while (1)
{
cout << "我是主线程... " << endl;
sleep(1);
}
return 0;
}
此时我们就发现,一旦线程出现异常,是整个程序就会崩溃,这就线程的健壮性较低,我们把这一特性称之鲁棒性.
此时此时线程结束只能存在两种情况,一是正确跑完了,一是不正确跑完了.此时我们就可以谈join接口的第二个参数了,它是给主线程返回一个指针,该指针指向一片空间,不过我们返回的时候这片空间不要被销毁.
void *callback(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "我是新线程 线程id" << (char *)args << " cnt " << cnt << endl;
sleep(1);
cnt--;
}
return (void *)10;
}
int main()
{
pthread_t tid1;
pthread_create(&tid1, nullptr, callback, (void *)"thread1");
void *ret = nullptr;
pthread_join(tid1, &ret);
cout << "线程等待结束 ret " << (long long)ret << endl;
sleep(5);
return 0;
}
由于线程遇到异常整个程序就会退出,也就是我们不能使用exit函数了,不过线程库还是给我们准备了一个接口.
void pthread_exit(void *retval);
void *callback(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "我是新线程 线程id" << (char *)args << " cnt " << cnt << endl;
sleep(1);
cnt--;
}
pthread_exit((void *)10);
}
int main()
{
pthread_t tid1;
pthread_create(&tid1, nullptr, callback, (void *)"thread1");
void *ret = nullptr;
pthread_join(tid1, &ret);
cout << "线程等待结束 ret " << (long long)ret << endl;
sleep(5);
return 0;
}
线程取消
除了线程自动退出外,我们也是可以在主线程中手动取消一个线程的,这里需要借助一个函数,不太常用
int pthread_cancel(pthread_t thread);
void *startRoutine(void *args)
{
while (true)
{
cout << "thread 正在运行..." << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, startRoutine, (void *)"thread1");
(void)n;
sleep(3); // 代表main thread对应的工作
pthread_cancel(tid);
cout << "new thread been canceled" << endl;
void *ret = nullptr;
pthread_join(tid, &ret);
cout << "main thread join success, ret: " << (long long)ret << endl;
sleep(1);
return 0;
}
.使用这个函数来取消线程,线程的退出结果是-1.这个-1就是一个宏
#define PTHREAD_CANCELED ((void *) -1)
线程栈
上面有关线程常见的接口我们已经谈完了,这里就剩下一个内容了,tid究竟是什么?我们按十六进制打印下.
void *callback(void *args)
{
while (1)
{
}
pthread_exit((void *)10);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, callback, (void *)"thread1");
while (1)
{
cout << "我是主线程... ";
printf("新线程id %p\n", tid);
sleep(1);
}
return 0;
}
他就是一个地址,我们知道线程是一个独立的执行流,他在运行的过程中一定会产生数据,例如调用函数,定义局部变量,这个时候线程一定需要自己的独立的栈结构.前面我们一直说,Linux没有为线程提供独立的线程数据结构,但是它给我们提供了一个线程库,这个库做的非常好,其中线程库中就有一个线程栈,当我们程序遇到创建线程的接口,此时我们物理内存还没有线程库,OS会把存放在磁盘的线程库加载到物理内存中,然后通过页表映射到共享区.
也就是对于没一个线程,我们都可以理解成加载一个线程库,那么请问我们计算机运行的线程可是不少的,要不要被管理呢?是的,我们就要把线程描述组织,其中我们需要描述线程,它可以认为是一个结构体,我这里给大家假设性的演示一下.
struct thread_info
{
pthread_t tid;
void* stack; // 线程栈
};
其中,我们得到的tid值就是这个结构体的地址,关于线程栈,我给大家演示一下.我们知道,线程是共享进程的,也就是如果我们定义一个全局变量,我们是都可以看到的.
void *callback(void *args)
{
char *name = static_cast<char *>(args);
while (1)
{
cout << "我是新线程 " << name << " 我看到一个全局变量 g_val: "
<< g_val << " 地址是 " << &g_val << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, callback, (void *)"thread1");
pthread_create(&tid2, nullptr, callback, (void *)"thread2");
pthread_create(&tid3, nullptr, callback, (void *)"thread3");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}
这个时候,我们可以看到所有的线程都是可以看到这个全局变量,也就是如果我们一个线程修改了,其他的线程看到都是修改过的值(这里先不谈原子性).
局部存储
那么我们是不是可以把这个全局变量让各个线程私有呢?除了我们可以在自己的函数体内定义,我们可以给全局变量上面加上一个修饰词,这样我们看到 变量就不一样了,修改的就是自己线程的值,不会影响其他的.
using namespace std;
__thread int g_val = 10;
void *callback(void *args)
{
char *name = static_cast<char *>(args);
while (1)
{
cout << "我是新线程 " << name << " 我看到一个全局变量 g_val: "
<< g_val << " 地址是 " << &g_val << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, callback, (void *)"thread1");
pthread_create(&tid2, nullptr, callback, (void *)"thread2");
pthread_create(&tid3, nullptr, callback, (void *)"thread3");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}