手把手写c++服务器(16):服务端多线程并发编程入门精讲(代码片段)

沉迷单车的追风少年 沉迷单车的追风少年     2022-12-09     470

关键词:

前言:相比于Go语言这种原生支持并发、自动垃圾回收的服务端“天选之子”,C++的多线程编程显得臃肿、困难。但是在C++服务器编程当中,多线程是一道绕不开门槛,是提高应用程序响应和性能的重要利器,能够隐藏诸如I/O这样耗时的操作延迟。特别是C++11引入了std::thread之后,C++对并发的支持显得异常强大。这篇博客做一个入门级的总结,以便日后讲解服务端编程的知识。

目录

线程与进程

拥有资源

调度

系统开销

通信方面

Go语言并发-借鉴解读

Go语言并发编程

Go语言主动垃圾回收

Go语言原子函数和互斥锁处理共享资源竞争问题

Go语言通道缓冲区探索

C++的角度看Go语言

成员函数API手册

创建线程:hello world!

编译方法:

用lambda表达式描述多线程

传递参数给入口函数

决定线程创建后的状态:join阻塞进程和detach守护线程

管理线程API :yield、get_id、sleep_for、sleep_until

常用场景:初始化任务过程中的一次调用

进阶实验:加上每个id观察线程执行先后顺序

网络编程中的多线程实例

写在后面的话

参考:


线程与进程

这是一道后端工程师必会的面试题,八股文入门级。

拥有资源

进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属于进程的资源。

调度

线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。

系统开销

由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、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方法。

(constructor)

Construct thread (public member function )        构造函数

(destructor)

Thread destructor (public member function )      析构函数

operator=

Move-assign thread (public member function )  赋值重载

get_id

Get thread id (public member function )                获取线程id

joinable

Check if joinable (public member function )          判断线程是否可以加入等待

join

Join thread (public member function )                    加入等待

detach

Detach thread (public member function )              分离线程

swap

Swap threads (public member function )               线程交换

native_handle

Get native handle (public member function )       获取线程句柄

hardware_concurrency [static]

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++服务器(34):高并发高吞吐io秘密武器——epoll池化技术两万字长文(代码片段)

本系列文章导航: 手把手写C++服务器(0):专栏文章-汇总导航【更新中】 前言:前文手把手写C++服务器(31):服务器性能提升关键——IO复用技术【两万字长文】介绍了IO复用技术,其中重点比较了... 查看详情

手把手写c++服务器(21):linuxsocket网络编程入门基础(代码片段)

本系列文章导航:手把手写C++服务器(0):专栏文章-汇总导航【更新中】前言:刚开始写C++服务器的时候,我们进行网络编程肯定是使用socketAPI,等熟练之后,会根据我们自己的需要,封装... 查看详情

手把手写c++服务器:专栏文章-汇总导航更新中

手把手写C++服务器(1):网络编程常见误区手把手写C++服务器(2):C/C++编译链接模型、函数重载隐患、头文件使用规范手把手写C++服务器(3):C++编译常见问题、编译优化方法、C++库发... 查看详情

手把手写c++服务器(36):手撕代码——高并发高qps技术基石之非阻塞recv万字长文(代码片段)

本系列文章导航: 手把手写C++服务器(0):专栏文章-汇总导航【更新中】 前言:没有什么东西是永恒,没有什么方案是万能,阻塞模式和非阻塞模式各有利弊。创建socket是默认阻塞的。但是在高并发多Q... 查看详情

手把手写c++服务器(22):linuxsocket网络编程进阶第一弹(代码片段)

前言:前面一篇文章手把手写C++服务器(21):Linuxsocket网络编程入门基础,讲解了如何建立socket连接、如何转换/使用socket地址、如何绑定/监听/发起/接受/断开/终止/关闭连接。socket博大精深,进阶会多写几弹&... 查看详情

手把手写c++服务器(22):linuxsocket网络编程进阶第一弹(代码片段)

前言:前面一篇文章手把手写C++服务器(21):Linuxsocket网络编程入门基础,讲解了如何建立socket连接、如何转换/使用socket地址、如何绑定/监听/发起/接受/断开/终止/关闭连接。socket博大精深,进阶会多写几弹&... 查看详情

手把手写c++服务器(25):万物皆可文件之socketfd(代码片段)

 本系列文章导航:手把手写C++服务器(0):专栏文章-汇总导航【更新中】 前言:大家一定听说过在Linux当中,万物皆是文件,任何客观的存在都是以文件形式呈现。前面讲socket编程的时候(手把手写C+&... 查看详情

手把手写c++服务器(35):手撕代码——高并发高qps技术基石之非阻塞send万字长文(代码片段)

本系列文章导航: 手把手写C++服务器(0):专栏文章-汇总导航【更新中】  前言:创建socket是默认阻塞的。但是在高并发多QPS的场景中,阻塞模式会极大程度上影响并发性,使之并发名存实亡。而send函... 查看详情

手把手写c++服务器(33):linux常用命令合集

 本系列文章导航: 手把手写C++服务器(0):专栏文章-汇总导航【更新中】 前言:服务端编程的过程当中,各种常用的命令行也会大量使用;熟悉常用Linux命令不仅仅是运维的基本要求,也是一个主程的基本门槛。这里... 查看详情

手把手写c++服务器(38):面试必背!linux网络socket编程必会十问!(代码片段)

 本系列文章导航: 手把手写C++服务器(0):专栏文章-汇总导航【更新中】目录1、说一下客户端和服务端socket建立连接和关闭连接的过程2、如何将一个socket设置成非阻塞模式3、什么是socket三大属性?4、阻塞模... 查看详情

手把手写c++服务器(31):服务器性能提升关键——io复用技术两万字长文

 本系列文章导航:手把手写C++服务器(0):专栏文章-汇总导航【更新中】 前言:Linux中素有“万物皆文件,一切皆IO”的说法。前面几讲手撕了CGI网关服务器、echo回显服务器、discard服务的代码,但是这几个一次只能监听... 查看详情

手把手写c++服务器(15):网络编程入门第一个tcp项目(代码片段)

前言:前面一篇博客讲述了第一个UDP项目,这篇博客来讲一讲TCP。TCP建立容易,销毁困难;TCP接收容易,发送困难。我们都知道TCP是一个可靠的协议,但是真的不会丢包吗?如何安全地关闭TCP连接?... 查看详情

手把手写c++服务器(19):序列化数据网络传输解决方案(代码片段)

前言:数据传输是服务器编程必须要面临的问题之一,原始数据传输是非常脆弱的,序列化传输是业界常用的方法,其中谷歌的PB方案广受欢迎,常用作项目中主要的解决方案,值得C++服务器编程者... 查看详情

手把手写c++服务器(26):常用i/o操作创建文件描述符

本系列文章导航:手把手写C++服务器(0):专栏文章-汇总导航【更新中】 前言:通过上一篇文章(手把手写C++服务器(25):万物皆可文件之socketfd),Linux万物皆文件的一定深入人心。如何操作这些文件?I/O函数将震撼登场!第一... 查看详情

手把手写c++服务器(17):自测!tcp协议面试经典十连问(代码片段)

前言:前面一篇文章《手把手写C++服务器(15):网络编程入门第一个TCP项目》介绍了一个简单入门级的TCP项目,这一篇文章重点讲一讲面试常见的TCP协议相关的十个问题,都是后端开发程序员必知必会的经典... 查看详情

手把手写c++服务器(17):自测!tcp协议面试经典十连问(代码片段)

前言:前面一篇文章《手把手写C++服务器(15):网络编程入门第一个TCP项目》介绍了一个简单入门级的TCP项目,这一篇文章重点讲一讲面试常见的TCP协议相关的十个问题,都是后端开发程序员必知必会的经典... 查看详情

手把手写c++服务器(28):手撕cgi通用网关接口服务器代码(代码片段)

 本系列文章导航:手把手写C++服务器(0):专栏文章-汇总导航【更新中】 前言:前文《手把手写C++服务器(26):常用I/O操作、创建文件描述符》《手把手写C++服务器(27):五大文件描述符零拷... 查看详情

手把手写c++服务器(27):五大文件描述符零拷贝控制总结(代码片段)

 本系列文章导航:手把手写C++服务器(0):专栏文章-汇总导航【更新中】 前言:前文《手把手写C++服务器(26):常用I/O操作、创建文件描述符》、《手把手写C++服务器(25):万物皆可文件之sock... 查看详情