多线程编程
文章目录
1创建第一个线程
背景知识
引例
你负责管理一个和尚Buddhist、一个秀才Confucian
安排他们一天的工作:
和尚:念经100遍
秀才:念经500遍
引例
问题:
(1)他们俩不能同时干活,只能一个接着一个
(2)你也不能干自己的活,必须等同他们干完了。。
根本原因:函数调用是串行的!!
引例
函数调用是串行的:
只有函数返回之后,才能继续 往下运行下一个函数。
:如何让他们各干各的?
:如何不必等待他们?
线程
线程 :Thread
线程技术用于实现并发的任务,让多个任务可以同 时运行。
换一种说法:把每个任务放在各自的线程中运行。
线程
理解线程的运行模式
主线:main函数本身这条线,称为主线。
①②: 使用Thread技术创建的线程,它们用于运行 并发的任务
创建一个线程
( 本教程使用第三方库: OSAPI )
// 定义一个类
class MyTask : public OS_Thread
{
private:
virtual int Routine()
{
// 线程体: 执行它的任务
}
};
创建一个线程
// 运行一个线程
MyTask
task; task.Run();
创建线程,就是这么简单!
线程的调度
所有的线程分享CPU,而CPU只有一块。。。
线程调度:
(1)所以每个线程都要自觉地让出CPU,让别的线程 也有机会被运行。使用Sleep
(2)操作系统让会把时间分割成很细的小片,让每个 线程都有机会运行几毫秒,轮流运行。
(总体上 感觉是每个线程在同时运行)
线程的调度
如何让出CPU?
:使用Sleep函数让一个线程处于“睡一会”
当它睡下的时候,就把CPU让给其他线程了。操作系 统会统一调度,决定该运行哪一个线程。
( 这里大致理解一下,后面会有具体解释)
让它们同时工作
把和尚和秀才的任务用线程来实现吧。。。
小结
- 认识到函数调用是无法实现并行的任务的
- 初步理解线程的概念,创建第一个线程
- 学会用Sleep来让出CPU
- 主线程main: 它一旦退出,程序就退出。那么其 他线程也就没了。
(所以不能让主线程轻易退出)
2线程的调度, sleep的使用
进程与线程
• 进程:当Task1.exe被加载到内存中运行时,这个 运行着的实例称为一个进程。
( 可以在任务管理器中查看)
Task1.exe称为程序文件
Task1.exe可以被同时多次运行,每运行一次,则一 个进程被创建。
线程的调度
一个进程中可以创建多个线程。其中至少有一个主 线程(main线程)。
由操作系统
来负责安排调度:决定哪一个线程被运 行。
(注意:是操作系统决定了一切,我们只是来了解 操作系统的行为,并用以达成我们的任务目标)
线程的调度
本PPT的目的:了解调度过程中的一般性原则,但并 不需要关注具体的调度算法。
不同的操作系统,其调度算法可能是不一样的。
但是它们都遵循同一原则:让所有的线程有机会运 行
。
时间片法
时间片法是一种普遍采用的调度算法。
介绍此算法的目的:辅助理解什么是"调度"。
基本原理:操作系统把CPU时间划分为多个均等的时 间片。例如,每5ms一个时间片,在每个时间片内运 行一个线程。
总体上看,各个线程是被轮流运行的。
时间片法
线程切换:
(1)把当前的线程切到后台,进入队列等待
(2)从队列中取得一个正在排队的线程,运行之 (3)5毫秒后,再次切换
把这个队列称为候选队列,表示这里面的线程都希 望自己被立刻运行。
时间片法
我们需要知道:
(1)使用Sleep,可以主动让自己的线程提前让出CPU。
(2)Sleep时间到的时候,该线程并不是被立即执行,而是进入了候选队列。
(3)操作系统是如何在候选队列里来挑选下一个线程的呢? 不同的操作系统方法可能不一样,只需要大致了解。
关于第2点:比如,一个程序要Sleep"睡眠"一秒,则在一秒 内该线程不被调度。当一秒以后,该线程苏醒,进入候选队 列。
优先级
某些操作系统可能支持线程的优先级, Priority。
也就是说,允许我们在创建线程的时候,指定一下线程的优 先级。如果优先级较高,则该线程在运行的时候拥有较高的 机会被调度。
但是不推荐使用优先级这个特性
,因为:
(1)并不是所有的操作系统都支持优先级
(2)优先级无法定量:倒底有多优先,是个说不准的事情。
合理利用Sleep
所以,关于线程调度,我们干预的手段是Sleep。它 是所有的操作系统都支持的。
通过积极的Sleep,让出CPU,来达到有效地调度。
再次强调:Sleep不是一个精确的东西,在“醒来” 之后不能保证该线程立即被调度。
所以,你如果 Sleep设定了N毫秒,那么实际间隔的时间一般会大 于N。
合理利用Sleep
Sleep一般会支持毫秒量级。
在OSAPI中提供了两个函数:
static void Msleep(int milli_sec);
static void Sleep(int sec);
3线程的创建与启动
线程的创建与启动
看起来很简单:
(1)定义一个MyTask类,继承于OS_Thread,重写线程主 函数Routine()
(2)使用这个类
MyTask task;
task.Run();
注:在多数系统上,线程的创建和启动是一个连续的动 作。
线程的创建与启动
线程属于系统级资源,可以在一个“资源监视器”中查看一个进程中的 线数。
打开方法: 任务管理器| 性能 | 资源管理器
线程的创建与启动
不要把创建线程和"函数调用"混淆
MyTask task;
task.Run(); // 这是创建线程
而不是
task.Routine(); // 这是函数调用
区别:
创建线程是操作系统来完成的,它创建一个线程 实体,该线程的主函数是Routine()。即,线程的入口。
线程的创建与启动
Run(): 相当于对OS说:
“请创建一个线程,入口函数为Routine()”
OS则根据程序员的要求,创建一个线程实体来运行, 线程的主函数就是Routine()。
“线程”的两种语境
两种语境:
(1) OS_Thread
它是一个C++类/对象,封装了线程相关数据和操作
(2) 线程实体
由操作系统创建的实体(跟进程类比)。通常我们 说的线程指的是这个,它是一个运行时
的概念。
“线程”的两种语境
MyTask task; // 这只是创建一个C++里的对象 task.Run(); // 这里"线程"才被真正的创建
Run()的内部告诉OS来创建一个线程
线程是有限的资源
一个进程中的最大线程数是有限制的,一般为几千 到几万。
但是,在工程实践中一般线程数最多是几十个
。线 程数不宜过多,因为线程调度本身也是有成本的
。
注:如果你发现必须使用上百个线程,那往往意味 着你的设计存在问题。
小结
- 线程启动时,调用的是Run(),而不是Routine()
调用Run()时,操作系统会为我们创建一个线程实体, 该线程的主函数是Routine()。
4线程的停止与回收
线程的停止
线程的停止:当return语句被执行时,表示该线程正常 退出。
int Routine()
{
printf(“do something \n”);
return 0; // 线程的主函数退出
}
return语句被执行、Routine()返回、线程退出
线程的异常停止
异常停止:主线程(主程序)退出的时候,有线程 正在运行。。。所有线程都被立即终止。
这种终止是不正常的: 因为它可能正在处理某个任 务,从而造成了不完整数据。。。
比如:
它正在录制一个文件,录了半个小时,数据还没保 存呢。。。不能突然关闭啊!
线程的异常停止
int main()
{
Buddhist task1;
task1.Run(); // 线程①
Confucian task2;
task2.Run(); // 线程②
getchar(); // 输入回车,主线程退出
return 0;
}
如何让其正常终止呢?
线程的正常终止
一般的方法:首先,设置标识量 (如 m_quitflag)
在线程主函数Routine()里,检查m_quitflag,当 m_quitflag为true时意味着应该退出。
线程在处理其任务的时候,不停地检测标识量,及 时地退出线程。在退出的时候,保存当前任务的进 度,以便下次继续。或保存所有其他需要保存的数 据。
线程的正常终止
添加标志量,用于控制线程的退出
class MyTask
{
public: int Routine()
{
while( !m_quitflag )
{
} // 线程函数里,检查之
return 0;
}
private:
bool m_quitflag; // 为true时表示应该退出线程
};
线程的回收
但是:仅仅设置标识量还不够。。。还需要等待线程退出。。。
int main()
{
// …
getchar();
// 等待线程退出
task1.m_quitflag = true; OS_Thread::Join(&task1);
return 0;
}
使用静态函数Join(),来等待目标线程退出。。。
线程的回收
Join()函数的作用:
(1)等待目标线程的退出
(2)回收这个线程的相关系统资源(记住线程的个 数是受限的)
线程的回收
Join的调用:当一个线程A要退出时,由另一个线程调用 Join来回收线程A。
注意:Join不能回收自已!
int Routine()
{
Join(this); // 错!!一个线程是不能Join自己的
return 0;
}
由主线程、或者任何的另外一个线程来执行Join
线程的回收
class MyTask : public OS_Thread
{
public:
void Stop()
{
m_quitflag = true;
Join(this); // 这样有问题吗??
}
};
小结
Join函数的作用
(1)回收目标线程相关的系统资源
(2)如果目标线程尚未退出,则一直等待,直到其退出。
注意:
首先,通知其退出
然后,等待其退出(它的退出、善后是需要一定时间的)
Join的书写位置:不能只看字面上的位置,而是要从运 行时的角度来看待问题。
5线程间共享数据 - 互斥锁
线程间共享数据
多个线程间可以共享数据:
(1)全局对象 (2)堆对象(动态创建的对象)
例如,
int number = 123; // 全局变量
在不同的线程里,都可以访问它
(在VC中演示,两个线程TaskA,TaskB)
数据的完整性
定义一个数组
char key[16];
规定:key的每一个元素的值都必须相等,否则视为 不完整的。
例如,key[0],key[1],…,key[15]的值全为100
数据的完整性
两个线程:
KeyGenerator: 定时生成key、更新key KeyChecker: 获取key、检验其完整性
在VC中演示
数据的完整性
数据不完整的根本原因:
线程在运行时,可能会在任意位置被切换。
例如,Generator正在更新数据,更新到一半的时候 被切换了。此时,Checker来访问这个数据,必然是 不完整的。
那么,如何保证数据的完整性呢?
互斥锁
当多个线程同时访问一块内存,就有可能出现的数 据不完整的问题。
此时,我们需要一种机制来“同步
”各线程对它的 访问。(所谓“同步”,是指协调、安排,使之步 调一致)
这种机制就是“互斥锁
”机制。
互斥锁
互斥锁,C++里一般称为Mutex, Java里则一般称为Lock。
互斥锁的使用:
“在访问共享数据之前,先获取锁(Lock);
在访问完毕后,释放锁(Unlock);”
互斥锁机制:
“在一个线程获取锁(Locked)之后,另一个线程的Lock 操作会一直等待(阻塞)
,直到该锁被释放(Unlocked)”
互斥锁
互斥锁的使用模式:
// 1. 创建全局对象,或堆对象
OS_Mutex g_mutex;
char g_data[128];
// 2. 在线程中要访问g_data,必须先获取锁
g_mutex.Lock(); // 此函数会阻塞,一直等待拥有锁
for(int i=0; i<128; i++) g_data[i] = i; g_mutex.Unlock(); // 释放锁
减少占有时间
使用原则:当一个线程占有锁时,应该尽快地完成 对共享数据的访问。因为别的线程还在等待这个锁 呢。
一般策略:直接把数据拷贝一份出来,然后再做处 理。(假设处理数据需要较长时间)
小结
- 多个线程之间,可以通过全局对象/堆对象来共 享数据
- 当访问共享数据时(有读有写),为了保证数据 的完整性,需要使用互斥锁机制
- 一个使用原则:尽量缩短对lock的占有时间
6可重入的函数 (线程安全的函数)
可重入的函数
可重入(reentrant)的函数,又称线程安全(thread safe)的函数。
是指一个函数,在多个线程里同时调用(并发调用) 的时候,其功能仍然正常。
可重入的函数
相反地,在并发调用时功能出错的函数,就称为不可重入的函数
(线程不安全的函数Thread Unsafe)
例如,下面的函数用于求和
int sum(int n)
{
int result = 0;
for(int i=1; i<=n; i++)
{
result += i;
}
return result;
}
可重入的函数
线程1: 在sum中运行的时候被切换
被切换: i : 50
线程2:
result: 5050
线程1: 从i=50继续运行时候,原有的状态(变量。。) 发生了改变
i: 50
result: 5050 + 51 + 52 + … + 100
可重入的函数
判断一个函数是否是可重入的:
(1)在单线程的情况下,该函数表现正常 如果单线程也不行,说明这个函数写错了
(2) 在多线程并发调用此函数时,该函数仍然表现 正常。 则称为该函数是可重入的。
可重入的函数
以下函数很可能是不可重入的:
(1)一个全局函数(写在类体之外的函数) 如果它借助于全局对象来实现,并且有写操作,那 么就是不可重入的。
(2) 一个类的成员函数 它访问并修改了成员变量,那么一般情况下它就是 不可重入的。
可重入的函数
如何将不可重入的函数,改为可重入的?
(1) 不借助外部的变量来实现
尽量用本函数内定义的局部变量来实现。
或者在本函数动态创建对象、并在退出前销毁对象
(没有外部依赖,不操作外部变量)
(2) 实在不行的话,加上互斥锁控制
(回到上一讲)
注意
在线程调度过程中,当线程的时间片用完被切换后, 过一段时间会重新接着运行。
当然,当接着运行的时候,由于你使用了全局变量, 而全局变量此时发生了改变。所以函数原有的逻辑 受到了影响。
7线程间的通知机制 - 信号量
引例
生产者 – 消费者 问题
Producer: 生产者,每隔几秒生成一个物品,放到 缓冲区里。
Consumer: 消费者,一旦发现缓冲区里有物品,即 刻取走。
(一个写,一个读)
引例
Producer Consumer
放入 取走
问题:如何及时取走?
(及时:一旦放入,即刻取走)
轮询机制
Consumer线程不知道何时有物品可取,只是不停地去查询。
(比 如,每隔50ms查询一次,以便及时取走)
while(1)
{
OS_Thread::Msleep(5);
g_mutex.Lock();
if(g_count > 0)
{
}
g_mutex.Unlock();
}
轮询的间隔不能太大:如果10秒钟才轮询一次,那缓冲区里的物 品可能已经堆积如山了。。。
轮询机制
轮询机制的缺点:
查询不能太频繁(浪费cpu),也不能太不频繁 (缓冲区满)。难以把握。
需要设计一个合理的轮询机制
所以,最好是有一个通知机制:生产者把物品放进 去之后,通知到消费者。消费者在接到通知之后, 再去取物品。
线程间通知机制:信号量
信号量:Semaphore,用于实现线程间的通知机制
(和Mutex一样,是一个系统级对象)
OS_Semaphore g_sem;
第一个线程: Producer
g_sem.Post(); // 通知
第二个线程:Consumer
g_sem.Wait(); // 等待通知
线程间通知机制:信号量
信号量的值:0, 1, 2, …, N, …
也就是说,生产者可以一次放入多个物品,并将信 号量的值增加。
g_sem.Post(); // 信号量的值加1
g_sem.Wait(); // 信号量的值减1.如果信号量的值 为0,则线程进行等待状态。
线程间通知机制:信号量
根据实际情况,可以将信量号的值初始化为0或正数
OS_Semaphore g_sem(0); OS_Semaphore g_sem(10); // 已有10个量
一般来说,信号量的初始值由你的实现应用中对应 的那个量来决定,比如,缓冲区中的物品的数量
线程间通知机制:信号量
超时等待
int ret = g_sem.Wait(1000); // ms
if(ret != 0) // 如果返回值不为0,表明已经超时
{
// 超时处理
}
超时之后想做什么处理?由您自己决定
小结
(1) 轮询机制可以解决问题,但性能不佳 轮询机制也很常用
(2) 用信号量实现线程间的通知机制 线程在等待信号量的时候,它是不占cpu的,相当于被阻塞的状态。
(3) 理解信号量的值的意义
8在MFC和Qt项目中使用OSAPI
OSAPI 跨平台
OSAPI设计用于跨平台编程 这套代码既适合于Windows,也适合于Linux
例如: Thread_Win32.cpp : 是Windows上的实现 Thread_Linux.cpp :是Linux上的实现 OSAPI 跨平台
OSAPI设计用于跨平台编程 这套代码既适合于Windows,也适合于Linux
例如: Thread_Win32.cpp : 是Windows上的实现 Thread_Linux.cpp :是Linux上的实现
OSAPI 与 WindowsAPI
OSAPI内部就是调用了WindowsAPI 只是做一个简单的封装,做成了类的形式。
演示:如何直接调用WindowsAPI来创建线程
OSAPI 与 afos
现已改名 afos ,放在官网的共享代码里 AfThread AfMutex AfSem AfTcpSocket …
OSAPI 与 MFC
由于OSAPI其实就是几个简单的类,非常独立,因而 可以在MFC项目中直接使用。
注:将项目设置为“不使用预编译头文件”
OSAPI 与 Qt
在Qt中也可以直接加入OSAPI,直接使用
学习资源 《C语言/C++学习指南》