手把手写C++服务器(16):服务端多线程并发编程入门精讲
Posted 沉迷单车的追风少年
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了手把手写C++服务器(16):服务端多线程并发编程入门精讲相关的知识,希望对你有一定的参考价值。
前言:相比于Go语言这种原生支持并发、自动垃圾回收的服务端“天选之子”,C++的多线程编程显得臃肿、困难。但是在C++服务器编程当中,多线程是一道绕不开门槛,是提高应用程序响应和性能的重要利器,能够隐藏诸如I/O这样耗时的操作延迟。特别是C++11引入了std::thread之后,C++对并发的支持显得异常强大。这篇博客做一个入门级的总结,以便日后讲解服务端编程的知识。
目录
决定线程创建后的状态:join阻塞进程和detach守护线程
管理线程API :yield、get_id、sleep_for、sleep_until
线程与进程
这是一道后端工程师必会的面试题,八股文入门级。
拥有资源
进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属于进程的资源。
调度
线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
系统开销
由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
通信方面
线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
Go语言并发-借鉴解读
Go语言并发编程
Go语言层面支持协程,将并发业务逻辑从异步转为同步,大幅提高开发效率;
在c++中,做并发编程目前主流的方案是事件驱动(单线程/多线程/多进程模型等),而事件驱动就需要一个IO多路复用的分发器(select/epoll),这样,就造成了业务逻辑的断开,在代码层面为异步模型,比如:
1).先是一段业务代码
2).调用IO(业务断裂)
3).IO完成后的后续处理逻辑;
而go中的协程的支持让这样的开发工作就轻松多了,按照同步的方式顺序写业务逻辑,遇到IO也没关系,一个线程中可以创建成上百万个协程,这个协程阻塞了就跑下一个,不需要应用代码层面来负责IO后续调度的处理;
比起自己用C/C++去封装底层或调用libevent之类的库,Go的优势是将事件机制封装成了CSP模式,编程变得方便了,但是需要付出goroutine调度的开销;
Go语言主动垃圾回收
毫无疑问这个好用,有了垃圾回收,不需要开发者自行控制内存的释放,这样可避免一堆问题(重复释放、忘记释放内存、访问已释放的内存等);
当然,c++11引入的智能指针(unique_ptr等)如果在程序中应用的普遍,也可以达到类似垃圾回收的目的;
GC带来的问题也是有的,会造成STW,会有程序停止调度的卡顿;
Go1.5的GC利用各种手段大大缩减了STW的时间。Go语言官方保证,在50毫秒的Go程序运行时间中因GC导致的调度停顿至多只有10毫秒。
Go语言原子函数和互斥锁处理共享资源竞争问题
参考我之前的博客:Golang——原子函数和互斥锁处理共享资源竞争问题
Go语言通道缓冲区探索
参考我之前的博客:Go语言——多线程相关的一点思考
C++的角度看Go语言
参考我之前的博客:从C++的角度看Go语言
成员函数API手册
下面是官网的成员函数API连接,作为工具使用。重点关注最常用的join和detach方法。
Construct thread (public member function ) 构造函数
Thread destructor (public member function ) 析构函数
Move-assign thread (public member function ) 赋值重载
Get thread id (public member function ) 获取线程id
Check if joinable (public member function ) 判断线程是否可以加入等待
Join thread (public member function ) 加入等待
Detach thread (public member function ) 分离线程
Swap threads (public member function ) 线程交换
Get native handle (public member function ) 获取线程句柄
Detect hardware concurrency (public static member function ) 检测硬件并发特性
创建线程:hello world!
创建线程的方式就是构造一个thread对象,并指定入口函数。与普通对象不一样的是,此时编译器便会为我们创建一个新的操作系统线程,并在新的线程中执行我们的入口函数。
用多线程开启一个hello world,源代码如下:
#include <iostream>
#include <thread>
using namespace std;
void hello () {
std::cout << "Hello world" <<endl;
}
int main () {
std::thread t(hello);
t.join();
return 0;
}
编译方法:
g++ -std=c++17 hello_word.cpp -o hello_word -lpthread
不知道如何使用g++编译的,可以参考本系列的第六篇博客:
记得加上 编译选项 -lpthread,不然会报错:
hello_word.cpp:(.text._ZNSt6threadC2IRFvvEJEEEOT_DpOT0_[_ZNSt6threadC5IRFvvEJEEEOT_DpOT0_]+0x2f): undefined reference to `pthread_create'
collect2: error: ld returned 1 exit status
用lambda表达式描述多线程
lambda也是C++ 11引入的新特性之一,是C++实现闭包的重要手段之一,本系列会专门写一篇文章介绍一下lambda这个新特性。
我们用lambda实现上面的hello world:
#include <iostream>
#include <thread>
using namespace std;
int main() {
thread t([] {
cout << "Hello World from lambda thread." << endl;
});
t.join();
return 0;
}
传递参数给入口函数
将需要传递的参数写在构造thread方法的第二个参数即可。举例如下:
#include <iostream>
#include <thread>
#include <string>
using namespace std;
void func (string url) {
cout << "welcome to cite " << url << endl;
}
int main () {
thread t(func, "www.pornhub.com");
t.join();
return 0;
}
决定线程创建后的状态:join阻塞进程和detach守护线程
刚才我们讲了线程是如何创建并指定参数的,下面重点讲一下thread的两种方法。
join | 等待线程完成其执行 | 调用此接口时,当前线程会一直阻塞,直到目标线程执行完成(当然,很可能目标线程在此处调用之前就已经执行完成了,不过这不要紧)。因此,如果目标线程的任务非常耗时,你就要考虑好是否需要在主线程上等待它了,因此这很可能会导致主线程卡住。 |
detach | 允许线程独立执行 | detach是让目标线程成为守护线程(daemon threads)。一旦detach之后,目标线程将独立执行,即便其对应的thread对象销毁也不影响线程的执行。并且,你无法再与之通信。 |
一旦启动线程之后,我们必须决定是要等待直接它结束(通过join),还是让它独立运行(通过detach),我们必须二者选其一。如果在thread对象销毁的时候我们还没有做决定,则thread对象在析构函数出将调用std::terminate()从而导致我们的进程异常退出。
管理线程API :yield、get_id、sleep_for、sleep_until
API | 功能 | 说明 |
yield | 让出处理器,重新调度处理各个线程 | 通常用在自己的主要任务已经完成的时候,此时希望让出处理器给其他任务使用 |
get_id | 返回当前线程id | 返回当前线程的id,可以以此来标识不同的线程。 |
sleep_for | 使当前线程执行停止指定时间段 | 让当前线程停止一段时间。 |
sleep_until | 使当前线程停止直到指定时间点 | 和sleep_for类似,但是是以具体的时间点为参数。 |
常用场景:初始化任务过程中的一次调用
在服务器编程的过程当中,启动的过程当中经常需要对多个任务进行初始化,但是多个线程之间共享资源,所以只需要初始化一次即可。利用call_once翻转flag的特性,多个线程都会使用init函数进行初始化,但是只会有一个线程真正执行它,具体是哪一个线程进行初始化,我们并不关心,这是一个典型的一次调用的场景。
其中call_once会在进阶部分讲授,期待一下:
#include <iostream>
#include <mutex>
#include <thread>
using namespace std;
void init () {
cout << "Now is initing……" << endl;
// do initing……
}
void worker (once_flag* flag) {
call_once(*flag, init);
}
int main () {
once_flag flag;
thread t1(worker, &flag);
thread t2(worker, &flag);
thread t3(worker, &flag);
t1.join();
t2.join();
t3.join();
return 0;
}
编译运行:
g++ -std=c++11 once_init.cpp -o once_init -lpthread
./once_init
Now is initing……
进阶实验:加上每个id观察线程执行先后顺序
这是一个非常有趣的现象,在上面的例子当中,由于三个线程是处于无约束的竞争状态,所以如果加上打印id的条件,打印出来的结果会是无序的,但是能保证只会初始化一次,具体代码和实验现象如下:
#include <iostream>
#include <mutex>
#include <thread>
using namespace std;
void init () {
cout << "Now is initing……" << endl;
// do initing……
}
void worker (once_flag* flag, int id) {
call_once(*flag, init);
cout << "id is " << id << endl;
}
int main () {
once_flag flag;
thread t1(worker, &flag, 1);
thread t2(worker, &flag, 2);
thread t3(worker, &flag, 3);
t1.join();
t2.join();
t3.join();
return 0;
}
我执行了三次,每一次的结果都不相同:
网络编程中的多线程实例
以上一讲TCP编程为例,我们在发送端sender中的main函数里面,开了一个线程进行send。
参见:https://xduwq.blog.csdn.net/article/details/118557515
源代码如下:
#include "Acceptor.h"
#include "InetAddress.h"
#include "TcpStream.h"
#include <thread>
#include <unistd.h>
void sender(const char* filename, TcpStreamPtr stream)
{
FILE* fp = fopen(filename, "rb");
if (!fp)
return;
printf("Sleeping 10 seconds.\\n");
sleep(10);
printf("Start sending file %s\\n", filename);
char buf[8192];
size_t nr = 0;
while ( (nr = fread(buf, 1, sizeof buf, fp)) > 0)
{
stream->sendAll(buf, nr);
}
fclose(fp);
printf("Finish sending file %s\\n", filename);
// Safe close connection
printf("Shutdown write and read until EOF\\n");
stream->shutdownWrite();
while ( (nr = stream->receiveSome(buf, sizeof buf)) > 0)
{
// do nothing
}
printf("All done.\\n");
// TcpStream destructs here, close the TCP socket.
}
int main(int argc, char* argv[])
{
if (argc < 3)
{
printf("Usage:\\n %s filename port\\n", argv[0]);
return 0;
}
int port = atoi(argv[2]);
Acceptor acceptor((InetAddress(port)));
printf("Accepting... Ctrl-C to exit\\n");
int count = 0;
while (true)
{
TcpStreamPtr tcpStream = acceptor.accept();
printf("accepted no. %d client\\n", ++count);
std::thread thr(sender, argv[1], std::move(tcpStream));
thr.detach();
}
}
写在后面的话
这一讲仅仅是C++多线程编程的皮毛,后面会继续在本系列更新线程的所有权、异常环境下的等待、共享数据问题、同步并发操作、内存模型、原子模型、有锁/无锁的并发、服务端编程常用并发模型、中断线程、多线程调试等等,敬请期待。
参考:
- 《C++并发编程实战》
- 《Linux高性能服务器编程》
- 《Linux多线程服务端编程》
- https://www.cnblogs.com/mmc9527/p/10427924.html
- https://mp.weixin.qq.com/s/SDVlU8DGWiDfCaHThnutFQ
以上是关于手把手写C++服务器(16):服务端多线程并发编程入门精讲的主要内容,如果未能解决你的问题,请参考以下文章
手把手写C++服务器(34):高并发高吞吐IO秘密武器——epoll池化技术两万字长文
手把手写C++服务器(36):手撕代码——高并发高QPS技术基石之非阻塞recv万字长文
手把手写C++服务器(35):手撕代码——高并发高QPS技术基石之非阻塞send万字长文
手把手写C++服务器(21):Linux socket网络编程入门基础