《C++游戏服务器开发入门到掌握》多线程编程

63、游戏服务器的基础

单核也可多线程,只不过要切换线程有额外开销,所以随着现在多核的出现,更适合多线程发展。目前CPU有4核、8核、16核,运行内存有8G、16G、32G。

可能的问题

  1. 死锁;
  2. 乱序;
  3. 并发访问数据;
  4. 低效率(为了防止前面问题的发生,需要做很多防御性工作)。

C++11带来的新概念

  1. 高阶接口:(async, future);
  2. 低阶接口:(thread, mutex);

例子:

void helloworld() {
	std::cout << "hello world\n";
}
int main() {
	// 一个进程本身有一个线程
	std::thread t(helloworld); // 开启一个线程
	std::cout << "hello main thread\n";
	t.join(); // 等待此线程运行结束(线程的终结)
}

64、例子:计算量一分二

  • 描述: 一千万次非常耗时的计算,可以折半分给额外的线程。注意要先创建线程去执行,再执行主线程的。否则按代码顺序执行,额外线程不仅没有利用起来,还增加了额外开销。
  • 再次使用lambda: 创建线程时传入的是std::function,传统的函数名(C类型的函数指针)会自动转换为function,如果需要传入参数,或接受返回值,可以用lambda实现(Lua中有用过,所以还能理解)。std::thread s([&value, iterBeg, iterEnd]() { value = func(iterBeg, iterEnd; }); // func为实际要执行的函数

函数内部如何知道是哪个线程在调用

  • 线程id: auto mainThreadId = std::this_thread::get_id();s.get_id();
  • 线程暂停: std::this_thread::sleep_for()/sleep_until();需要引入跟时间相关的头文件,100毫秒使用std::chrono::milliseconds(100)
  • 对比: 先用主线程执行一次,然后一人一半执行一次。分别打印两次耗时(auto begin = clock();),结果比例接近2:1,但不等于,因为开一个线程是挺费时的(包括构造、清理,为环境做准备)。

65、当线程间需要共享非const的资源,(问题引出)

  • 效率最高线程数量: std::thread::hardware_concurrency();返回效率最高的线程数量,但仅仅是个参考,实际情况一般会很复杂(服务器机器不止运行你这一个程序)。
  • 描述: 一千万次计算分为三份,给三个线程去计算总和。这次传入同一个值的引用,同时传入同一个对象的引用,用来自增统计次数。然后就发现统计的次数,和计算的总和,都比正确的值要少。
  • 原因: 变量在做自增操作时分三步:1、写入寄存器;2、寄存器中加1;3、写入内存。

66、(thread的构造和新问题)

  • 最好的方法: 如果没有必要的话,线程间不要共享资源。上面的问题用三个变量去接收,最后统一加上。这种方法也是最高效,最不容易出错的。
  • 线程构造函数: 创建线程对象的时候有两种方式:1、传入lambda表达式;2、函数名,后面跟需要传入的参数。后一种方式看似简洁,但对于引用的情况要借助于std::ref(c),掺杂起来就没有那么直观了。讲师推荐第一种。自己发现第一种报错也好查一些。
  • 言归正传: 如果需求是线程间还是需要共享一个变量,应该要怎么做呢?

67、阶段答疑(由学员朋友遇到的面试题展开的分析《构造与析构》)

68、thread和原子操作变量类型

  • 思路: count++的时候分三个步骤,如果能在这整个过程中,保证线程不被切换,也就是执行++的时候保证上一次++操作一定是完成了的。
  • 历史: 以前是各个库自己实现了这种方法,C++11以后标准委员会也发现了这个问题,给出了两种解决方法。
  • 方法: 第一种方法比较传统,很多都在用的。这里介绍第二种,看起来高大上一点,对于简单应用非常好用,但对于复杂应用,就会显得更复杂,很难保证正确性。
    历史: 从Java(只有封装和非封装两种用法)借鉴过来,根据自己的特性加了很多东西,针对性能可以有更高级的用法,不过要高级程序员才能驾驭。像我们简单的应用就用简单的就好。
    定义成员变量: std::atomic m_count;或者std::atomic_int m_count;(一些常见的基本类型都已经定义好了)。
    新的问题: 原子操作保证了执行过程中(成员变量++、--、+、-、*、/运算)线程不会切换,但是两个原子操作之间还是存在线程切换的问题。比如addCount()、addResource()都是原子操作,但是每次求(resource / count)不一定恒等于1。

69、临界区mutex1

  • mutex: 特点是比atomic更不容易出错。
  • 方案一: 新增成员变量mutex,两个成员方法lockMutex()和unlockMutex(),不过缺点是暴露了自己的接口,给别人使用时容易造成死锁:1、忘记使用unlock();2、调用了两次lock()。(使用起来很繁琐要很小心,越灵活越容易出错)(即使你保证很小心,但是碰到抛出异常的函数,还是会非常棘手,用起来很不方便)

70、临界区mutex2

  • 问题: 将接口暴露在外还有一个问题,用户即使是获取成员变量不改变它,也容易遗漏掉lock()的使用,毕竟是在多线程下进行的操作。
  • 解决: 所以在操作成员变量、多线程下执行的成员函数中包装好mutex的使用。
  • 问题: 但是还有问题:1、对于前面的抛出异常没有做处理;2、仅仅是获取成员变量的函数,却要一个中间变量来保存,看起来很丑陋(包括其他函数中return的地方总是要加unlock()的调用)。
  • 引申: 对于仅仅获取成员变量的函数,通常希望定义为const,但是内部mutex的操作又必须是要改变值的,此时需要将mutex成员变量定义为mutable
  • 思路: lock()和unlock()都是成对出现,不经让我们想到了C++的构造析构特性。可以利用一个类将这两个操作封装起来。我们可以自己简单地实现一个,不过局限性比较大,只能用来干这个事情。stl标准库则有一个Lock类有着更丰富的作用和用法,下节揭晓。
  • 疑问: 课程中去除原子变量,改用mutex后。printStep()还是会进入死循环的(因为对非原子的操作其他线程看不到改变)。但我本地并不能重现,由此想讨论下get函数是否需要进行atomic或者mutex保护。

71、临界区mutex3

  • std::lock_guard: 用标准库的std::lock_guard代替原来手写的Lock类。
  • 优点: 1、更灵活;2、能处理之前一种不能处理的情况。
  • 经典案例: 银行转账函数传入两个对象,锁住A、然后锁住B,然后转账。
void transferMoney(BankAccount &a, BankAccount &b, int money) {
	std::lock_guard<std::mutex> lockA(a.Mutex);
	std::lock_guard<std::mutex> lockB(b.Mutex);
	// 转账操作
  • 问题: 单线程的情况下都会死锁,自己向自己转账。
  • 解决: 在函数内部对两个账户取地址,判断如果相等就return。
if (&a == &b)
	return;
  • 问题: 多线程下A转给B,同时B转给A,同样会死锁。
  • 解决: 不管传入的顺序如何,固定锁操作的顺序,比如按地址从小到大的顺序。
if (&(a.Mutex) < &(b.Mutex))
	// 上面所有操作(包括转账操作)
else
	// 上面所有操作(因为此时lock_guard对象已成局部变量,所以转账操作也不能放在外面)
  • 问题: 代码要写很多,如果传入多个账户,要写更多(ifelse判断会非常复杂)。
  • 解决: 标准库封装了方法,使用起来非常简便。(讲师说课后想想还有没有更好的办法)
std::lock(a.Mutex, b.Mutex /* ... */); // 固定顺序全部锁住
std::lock_guard<std::mutex> lockA(a.Mutex, std::adopt_lock); // 告诉它构造时不用执行锁操作
std::lock_guard<std::mutex> lockB(b.Mutex, std::adopt_lock); // 告诉它构造时不用执行锁操作

72、thread的两种“死法”

  • 尝试: 只调用thread的构造函数,而不调用join(),程序会直接终止。等同于终止程序并调用C的abort(errorcode);函数。
    两种行为: 和操作系统相关,对于大部分的操作系统,thread的行为有两种:1、生成后程序员自己管理;2、生成后程序员完全不管。对于这两种情况用C++的构造析构没有办法完全模拟出来,所以就需要程序员显示指定行为。所以thread的析构函数就是做终止程序的操作。
    join(): 通常我们是自己管理,这也符合C++的编程风格。在调用前通常还需要判断`if (t.joinable())。
    detach(): 交给操作系统管理,在线程运行完的时候会自动调用析构。应用场景:1、事情简单,自己不想管理;2、不容易出错;3、生命周期比主程序小(否则在主程序运行完后就会把thread杀掉了,thread里面的析构会来不及调用)。
    问题: 如果程序变得复杂,自己管理线程也会碰到难题,比如中间抛出了异常导致后面的join()没有调用到。这个时候又可以利用构造析构来解决,类似于当时自己写的Lock类。
class ThreadGuard {
public:
	ThreadGuard(std::thread& t) : m_thread(t) {}
	~ThreadGuard()
	{
		if (m_thread.joinable())
			m_thread.join();
	}
private:
	std::thread& m_thread;
};

int main(int argc, char* argv[])
{
	std::thread j(joinWorker);
	ThreadGuard guard(j);
}

73、thread间的交互1

  • 目标: 主线程对全局变量的修改,可以控制其他一直监听的子线程。
  • 线程安全: 对于一些函数,尤其是C函数(比如printf()),都是线程安全的。但是std::cout就不是(连续的<<实为多个操作),多线程下输出会乱掉,需要加std::lock_guard lock(mutex);
  • 线程间的同步: 1、while循环(会占用大量CPU);2、在循环内部加std::this_thread::yield();(执行线程数量大于最大线程数量时,即使让出来了也会有其它线程抢占,所以没什么用(只有一点点作用));3、在循环内部加std::this_thread::sleep_for(std::chrono::seconds(1));每隔一段时间去检测(效果好多了,但也有很大的缺点,并且不是最完美的方法)。
  • top: linux命令,任务管理器。

74、thread间的交互2

  • 目标: 一个玩家看到的世界聊天消息,处理和加入能在不同的线程下同时进行。
  • time: linux命令,time ./a.out查看程序各种耗时。例子:0.39 user 0.13 system 3% cpu 15.836 total。执行用户代码、调用系统函数、cpu占用率、总共(实际感官)。
  • 伪多线程: 用了多线程,但实际同时只有最多一个线程在工作,反倒比单线程耗时更多。
  • 解决: 1、减少锁空间(比如主线程先锁再sleep的顺序可以调换下)(费力不讨好:当主线程先进入sleep但没有加锁,如果此时其它线程已经做完了工作,那么他们就会空循环并不断加锁解锁(回到了最初只用while循环空转的情况));
  • 疑问: 在减少锁空间的优化中,可以通过把锁的构造往后移实现,那能不能把锁的析构提前进一步优化呢?

75、thread间的交互3

  • 问题: 上一节在主线程做了费力不讨好的事。
  • 方案: 子线程中也加入sleep,减少空转时间。但结果还是没有单线程快。
  • 有意思的事: 子线程中加sleep反而总时间更快。原因:总时间是跟最慢的一环相关(主线程),而子线程空转的时候反而会增加负载,导致主线程运行更慢。
  • 引申: 如何评估多线程的使用:共享 / (共享 + 独立),总共要做的事情中,线程需要共享的事情的比例。这是个极端的例子,因为都是对globalList做处理,导致共享的事情占据了99%。所以这个多线程例子再快也没有单线程快(开线程、加锁、解锁都要耗时间)。
  • 例子: 将子线程的工作量稍微增加一点,跟单线程比起来就快一点了。
  • 问题: 想要把部分工作独立出来,把计算长度的工作先用一个Message变量保存,然后独立与mutex之外。但实际效果就好了一点点(因为增加了构造函数、数据转移、原子封装totalSize)。
  • 问题: 不管怎么优化都不是很理想,况且子线程中的sleep让人看起来很奇怪,参数也没有标准,需要不断调优。while死循环也会很耗性能,能不能有专门的消息通知呢?答案是肯定的。
  • 方案: C++定义了方法,可以不那么明显看到sleep,并且保证CPU占用率最低。ConditionVariable

76、thread间的交互4

  • 注意事项: 不要直接用cv.wait(lock);,因为线程在等待的过程中有可能被操作系统无缘无故唤醒,所以后面要加上自己的判断。就是说其他cv没有通知,也有可能被唤醒,然后认为满足条件了。
  • unique_lock: 相比于lock_guard可以中途释放锁,用来搭配condition_variable使用,wait(lock)的时候会暂时释放锁,然后检测后面的表达式,满足条件后才会重新获取锁资源。
#include 
std::condition_variable cv;
cv.notify_one(); // 随机(按照某种算法,底层的)唤醒一个线程。可以相对于某种特殊情况,做一个小小的特例和优化。
cv.notify_all(); // 全部唤醒(CPU会瞬间上去,然后马上又下来。如果代码老是出于这样的颠簸状态,性能会变得很差。)

参考自《C++游戏服务器开发入门到掌握》教学视频。

如有侵权,请联系本人删除。

你可能感兴趣的:(C++游戏服务器开发入门到掌握)