手把手写C++服务器(16):服务端多线程并发编程入门精讲

Posted 沉迷单车的追风少年

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了手把手写C++服务器(16):服务端多线程并发编程入门精讲相关的知识,希望对你有一定的参考价值。

前言:相比于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++服务器(16):服务端多线程并发编程入门精讲的主要内容,如果未能解决你的问题,请参考以下文章

手把手写C++服务器(34):高并发高吞吐IO秘密武器——epoll池化技术两万字长文

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

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

手把手写C++服务器(21):Linux socket网络编程入门基础

手把手写C++服务器(22):Linux socket网络编程进阶第一弹

手把手写C++服务器(22):Linux socket网络编程进阶第一弹