目录
-
- [一. 线程的概念](#一. 线程的概念)
-
- [1. 线程理解](#1. 线程理解)
- [2. 页表](#2. 页表)
- [二. 线程控制](#二. 线程控制)
-
- [1. 原生线程库](#1. 原生线程库)
- [2. 线程创建](#2. 线程创建)
- [2. 线程终止](#2. 线程终止)
- [3. 线程等待](#3. 线程等待)
- [4. 线程分离](#4. 线程分离)
- [5. 线程简单封装](#5. 线程简单封装)
- [三. 地址空间中的线程库](#三. 地址空间中的线程库)
-
- [1. 线程的独立栈](#1. 线程的独立栈)
- [2. 线程的局部存储](#2. 线程的局部存储)
一. 线程的概念
- 线程是进程内部的一个执行流, 执行分支;
- 线程是 CPU 调度的基本单位;
1. 线程理解
在进程的学习中, 一个进程是由其 内核数据结构 + 代码数据 构成的, 一个进程就是一个执行流;
若多个执行流并发或并行运行, 那么就需要创建多个不同的进程, 由操作系统调度每个进程;
由于进程的创建和调度成本较高, 提出了线程的概念;
线程: 在进程中额外创建线程的结构 TCP, 其保存当前进程的虚拟地址空间等资源, 多个线程共享同一份虚拟地址空间, 页表, 代码和数据等进程的资源, 使得操作系统减少进程创建和调度的成本;
在 Linux 中, 由于进程和线程的相似性, 将进程模拟线程来实现, 也就是将线程结构 TCP 复用进程结构 PCB, 并没有使用特别的调度算法或定义特别的数据结构表示线程; 线程也可以视为一个与其他进程共享某些资源的进程, 可以看作轻量级进程;
线程切换成本较低的原因:
- 进程和线程都需要进行切换内核栈和硬件上下文, 这是两者都必要的;
- 但若线程使用的相同的资源, 那么线程就不需要切换页表及虚拟地址空间;
进程的切换需要操作系统将新的虚拟地址空间等数据保存至寄存器中, 但这不是最重要的消耗;
虚拟地址空间和页表的切换会导致 CPU 的高速缓存失效 (局部性原理, CPU 的 cache 会预先加载部分可能被访问的数据以提高效率), 重新加载; 而线程切换的 cache/TLB命中的概率则会高许多, 降低消耗;
进程和线程的区别:
- 进程是资源分配的最小单位, 线程是 CPU 调度的最小单位;
- 进程可以拥有多个线程, 至少包含一个线程;
- 每个进程都有独立的地址空间, 代码数据, 进程之间的切换开销较大; 同一类的线程共享地址空间, 代码数据, 每个线程也都有自己独立的数据, 线程之间切换的开销较小;
线程的优点:
- 创建一个线程的代价比创建一个进程的代价要小得多;
- 调度线程比调度进程要容易得多;
- 线程占用的系统资源远小于进程;
- 可以充分利用多处理器的并行数量;
- 在等待慢速 IO 操作时, 程序可以执行其他任务;
- 对于计算密集型应用, 可以将计算分解到多个线程中实现;
- 对于 IO 密集型应用, 为了提高性能, 将 IO 操作重叠, 线程可以同时等待不同的 IO 操作;
线程的缺点:
- 性能损失, 若线程数量过多, 频繁的同步和调度会导致处理器的性能损失;
- 健壮性降低, 缺乏访问控制;
由于线程共享进程的资源, 那么一个线程的变化可能会影响其他线程, 线程之间是缺乏保护的; - 编程难度提高;
线程共享资源:
- 地址空间;
- 文件描述符表;
- 每种信号的处理方式;
- 当前工作目录;
- 用户 ID 和组 ID;
线程私有资源:
- 线程 ID (LWP);
- 一组寄存器;
- 线程独立栈;
- 错误码 errno;
- 信号屏蔽字;
- 调度优先级;
2. 页表
数据 IO 通常是以块为基本单位的, 一个块的大小为 4KB(在文件系统中, 一个块由磁盘的8个扇区组成, 单个扇区大小通常为 512Byte), 所以内存实际上也是以块(4KB)为单位划分的, 这种以块划分的区域被称为页框(页帧);
而在 32 位系统中, 虚拟地址空间的大小是 4GB, 即 2^32^ 个地址, 若页表采取 地址->地址 这种直接映射的方案, 物理地址和逻辑地址就需要 8 个字节, 若和各种标志位按 10 个字节算, 就需要 2^32^ * 10 Byte, 即 40GB 的内存大小, 显然是不可能的, 所以使用的是多级页表来进行映射;
在 32 位系统中, 通常将一个虚拟地址按比特位划分为三部分:
- 高 10 位比特位: 用于在页目录中定位页表项(页目录最多可以映射 2^10^ 个页表);
- 中 10 位比特位: 用于在页表项中定位页框地址(一个页表最多可以映射 2^10^ 个页框);
- 低 12 位比特位: 用于在页框中定位具体地址(2^12^ 为 4096, 刚好可以作为页框的偏移量);
这样, 即使每个物理地址都被映射的情况下, 页表的大小为: 4Byte * 2^10^ (一个页目录的大小) + 4Byte * 2^10^ * 2^10^ (2^10^个页表的大小), 即 4MB 左右的大小;
当线程进行内存操作的时候, 由虚拟地址找到页表, 再由 MMU 和 页表的属性 判断操作是否合法, 决定是否能够操作物理内存;
二. 线程控制
1. 原生线程库
由于在 Linux 中是通过进程模拟实现的线程(LWP), 所以是没有真正意义上的线程的; 所以操作系统是没有线程控制的相关接口, 最多提供轻量级进程操作的相关接口;
但为了用户的方便使用, 将轻量级进程操作的相关接口封装为线程库, 对上层用户提供线程控制的相关接口;
所以在编译多线程相关代码时, 必须加上选项: -lpthread, 链接线程库;
2. 线程创建
pthread_create() 函数, 创建一个新的线程;
cpp
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
参数:
- thread: 输出型参数, 线程创建成功后, 返回线程 ID;
- attr: 用于设置线程的属性, 若传入 空, 则使用默认属性创建线程;
- start_routine: 回调函数, 线程所执行的代码块, 新线程从 start_routine() 函数开始执行;
- arg: 回调函数的参数;
返回值:
- 若成功, 返回 0; 若失败, 返回错误码, thread 则未被定义;
例:
可以使用 pthread_self() 函数, 返回当前线程的 ID;
cpp
#include <pthread.h>
pthread_t pthread_self(void);
在命令行也可以使用 ps 命令的 -aL 参数打印线程的 ID
cpp
ps -aL
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
void* thread_test(void* arg)
{
while (1)
{
cout << "次线程: " << pthread_self() << endl;
sleep(2);
}
return 0;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, thread_test, nullptr);
while (1)
{
cout << "主线程: " << pthread_self() << endl;
sleep(2);
}
return 0;
}
可以看到同一进程的线程 PID 是相同的, LWP 不同;
2. 线程终止
线程终止有三种方式
线程函数 return ; 可以返回 void* 类型的对象(用于传递线程退出时的信息, 主线程等待次线程时接受);
类似在 main 函数中返回, 但在 main 函数返回, 进程终止; 也就是主线程返回, 进程终止;
线程调用 pthread_ exit() 函数 pthread_ exit() 函数, 退出当前线程;参数:
- retval, 用于传递线程退出时的信息, 主线程等待次线程时接受;
其他线程调用 pthread_cancel() 函数终止指定线程;
cpp#include <pthread.h> int pthread_cancel(pthread_t thread);
参数:
- thread, 指定线程的 ID;
返回值:
- 若成功, 返回 0; 若失败, 返回 非零;
注:
线程中不要使用 exit() 函数退出, 会导致进程退出;
进程退出, 此进程的所有线程都会退出;
3. 线程等待
pthread_join() 函数用于主线程等待次线程; 主线程需要等待次线程, 获取退出信息和回收资源;
cpp
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
参数:
- thread: 等待的线程 ID;
- retval: 二级指针, 输出型参数, 次线程的退出信息; retval 会根据终止的方式获取退出信息, 若等待的线程被 pthread_cancel() 函数取消退出的, 那么 retval 会指向 PTHREAD_CANCELED;
返回值:
- 若成功, 返回0; 若失败, 返回错误码;
4. 线程分离
pthread_detach() 函数, 可以将线程分离出去;
当次线程终止时, 由操作系统释放次线程的资源, 主进程无需等待次进程, 避免阻塞等待;
cpp
#include <pthread.h>
int pthread_detach(pthread_t thread);
参数:
- thread: 待分离线程的 ID;
返回值:
- 若成功, 返回 0; 若失败, 返回错误码;
5. 线程简单封装
- Thread.h
cpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <functional>
#include <vector>
using namespace std;
template<class T>
class Thread
{
typedef function<void*(T&)> func_t;
public:
Thread(func_t func = nullptr, const T& args = T(), const string& name = "none")
:_tid(0), _func(func), _args(args), _name(name)
{}
//
static void* threadroutine(void* ts)
{
((Thread*)ts)->_func(((Thread*)ts)->_args);
return 0;
}
// 线程调度
bool start()
{
int flag = pthread_create(&_tid, nullptr, threadroutine, this);
if (flag)
return false;
return true;
}
// 线程等待
void join()
{
if (_tid)
pthread_join(_tid, nullptr);
_tid = 0;
cout << _name << endl;
}
~Thread()
{
if (_tid)
join();
}
private:
pthread_t _tid; // 线程 ID
func_t _func; // 回调函数
string _name; // 线程名
T _args; // 回调函数参数
};
- test.c
cpp
#include "Thread.hpp"
void* thread(int i)
{
cout << "次线程: " << i << endl;
return 0;
}
int main()
{
vector<Thread<int> > threads;
for (int i=1; i<6; i++)
threads.emplace_back(thread, i, "thread-"+to_string(i));
for (int i=1; i<6; i++)
threads[i-1].start();
sleep(2);
cout << "---------" << endl;
for (int i=1; i<6; i++)
threads[i-1].join();
return 0;
}
三. 地址空间中的线程库
原生线程库本质是一个动态库, 那么程序运行时, 就需要将其从磁盘加载至内存中, 再从内存映射至进程的共享区中;
在 Linux 上, 是没有真正意义上的线程, 所以操作系统不会管理线程;
而是封装 '实现' 线程的线程库负责管理线程的, 类似 FILE 文件结构由 C 语言管理而不是操作系统;
而线程 ID 则是该线程TCB 在共享区中的起始地址
1. 线程的独立栈
线程之间的栈是相互独立的, 保证线程之间的隔离和互不干扰;
例:
可以看的 '同一' 局部变量的地址不同;
cpp
#include "Thread.hpp"
void* thread(int i)
{
int tmp;
cout << "次线程: " << i << ", &tmp: " << &tmp << endl;
return 0;
}
int main()
{
vector<Thread<int> > threads;
for (int i=1; i<6; i++)
threads.emplace_back(thread, i, "thread-"+to_string(i));
for (int i=1; i<6; i++)
threads[i-1].start();
sleep(2);
cout << "---------" << endl;
for (int i=1; i<6; i++)
threads[i-1].join();
return 0;
}
2. 线程的局部存储
__thread 选项, 可以将一个全局的内置类型对象设置为线程局部存储;
就是每个线程将会拷贝一份这个全局变量, 将其私有化;
例:
全局变量在添加 __thread 选项后, 不同线程的地址也不相同;
cpp
#include "Thread.hpp"
__thread int tmp;
void* thread(int i)
{
cout << "次线程: " << i << ", &tmp: " << &tmp << endl;
return 0;
}
int main()
{
vector<Thread<int> > threads;
for (int i=1; i<6; i++)
threads.emplace_back(thread, i, "thread-"+to_string(i));
for (int i=1; i<6; i++)
threads[i-1].start();
sleep(2);
cout << "---------" << endl;
for (int i=1; i<6; i++)
threads[i-1].join();
return 0;
}