符合c++14标准的网络和高并发编程

1. 网络编程


OSI四层模型
协议 作用
链路层 ARP/RARP ip地址到物理地址的映射
网络层 IP/ICMP 数据包的选路和转发/检查网络连接
传输层 TCP/UDP 数据的收发以及链路的超时重连
应用层 DNS(proto)/telnet 域名查询/远程连接
  1. ARP协议主要用来向自己所在的网络广播一个ARP请求,该请求包含目标机器的网络地址。此网络上的其他机器都将收到这个请求,但只有被请求的目标机器会回应一个ARP应答,其中包含自己的物理地址
    arp报文
    以太网帧
  2. 使用telnet在同网络机器中进行远程连接,在一台机器上使用tcpdump进行以太网帧捕捉,捕捉结果显示类型为ARP,ARP报文长度为28字节,被封装在以太网帧中,并且根据以太网帧头部信息长度和ARP报文长度来看,帧总长应为46字节,这里显示42字节表示不考虑尾部4字节的CRC字段(对头部和数据进行循环冗余校验)
    tcpdump检测结果
  3. 无状态( stateless )是指IP通信双方不同步传输数据的状态信息,因此所有IP数据报的发送、传输和接收都是相互独立、没有上下文关系的。这种服务最大的缺点是无法处理乱序和重复的 IP 数据报 。 比如发送端发送出的第 N 个 IP 数据报可能比第 N + 1个IP数据报后到
  4. 计算机电路先处理低位字节,效率比较高,因为计算都是从低位开始的。所以,计算机的内部处理都是小端字节序。在计算机内部,小端序被广泛应用于现代 CPU 内部存储数据;而在其他场景,比如网络传输和文件存储则使用大端序。
    大端序和小端序
  5. tcp/ip新感悟
    • tcp通信双方在连接初始化,即发送同步报文段中要确定MSS及扩大窗口因子的大小,这样可以有效防止ip模块进行分片处理,同步报文段显示的接收窗口win值都是真实的win值,不需要乘以2的窗口因子次幂
    • http和udp都是无状态协议,即每个http请求之间没有上下文关系,解决办法为设置cookie,服务器向客户端发送cookie,客户端每次请求时都会带上cookie(通过HTTP头部字段cookie),这样服务器就可以区别不同客户
    • ip模块通过接收和发送ip数据报输入队列和输出队列与网络驱动程序交互
    • 半关闭状态指的是某方将socket的写端关闭,表示数据已经发送完毕,此时socket发送缓冲区中的数据全部被刷新,接收方可以使用read系统调用返回0来检测对方是否关闭了写操作
    • tcpdump中显示的seq值有两个,被冒号分隔,冒号前是这次的seq值,冒号后是发送数据之后的seq值,ack值是其收到的tcp报文段序号值加1
    • tcp拥塞控制:
      • cwnd是由发送端引入的称为拥塞窗口的变量,假设网络状况良好,慢启动过程中每次rtt时间内都会连续发送cwnd/smss个报文段,并且也会接收cwnd/smss个ack,那么cwnd在一次rtt内就翻倍,这也是慢启动过程中cwnd呈指数型增长的原因
      • 发送窗口swnd其实是由cwnd和接收的ack所显示的对方的接收窗口rwnd二者中的最小值决定的,昭示了下次接收到新ack前可以往网络中写入多少个tcp报文段。
      • 快速重传个人理解为将ssthresh调整为max(未确认的字节数/2, 2*smss), 再调整cwnd为ssthresh加3倍的smss, 可见此时cwnd是远大于ssthresh的(但是ssthresh的大小和原来相比呢??),之后接收到新数据的ack后就将cwnd快速恢复恢复为ssthresh,进入拥塞避免阶段

1.1. 字符串处理函数

  1. strpbrk(char* s1, char* s2) 指向str1中任何属于str2字符的第一个出现位置的指针,如果在终止的空字符之前在str1中找不到str2的任何字符,则返回空指针。如果在str1中不存在str2的任何字符,则返回空指针。
  2. strcasecmp(char* s1, char* s2) 和 strcmp 相似,但strcasecmp不区分大小写,strcasencmpstrncmp 类似

1.2. IO模型

通过fcntl设置文件描述符属性为O_NONBLOCK,这样的文件描述符被称为非阻塞IO,当accept,recv,send等系统调用处理非阻塞IO时,会立即返回,若事件没有发生,则会返回-1并设置errno为EAGAIN或EWOULDBLOCK(其实二者相同),对connect而言则是EINPORCESS

1.3. IO复用

IO复用可以使程序同时监听多个文件描述符,IO复用函数可以向内核注册文件描述符及其关联的可读,可写,异常事件,并返回就绪事件的数量(通知进程事件已经就绪)

  1. epoll和select以及poll的区别在于epoll对应一组函数调用,如epoll_ctl,epoll_wait,并将就绪事件重新赋值给一个新event数组,这样节省轮询事件就绪的时间

  2. epoll,select,poll都会因为内核收到信号而返回-1并设置errno为EINTR

  3. epoll两种工作模式LT,ET在处理可读事件的区别在于

    • LT:如果此时缓存区没有可读数据,则epoll_wait不会返回EPOLLIN,如果此时缓冲区有可读数据,则epoll_wait会持续返回EPOLLIN
    • ET:如果此时缓存区没有可读数据,则epoll_wait不会返回EPOLLIN,如果此时缓冲区有可读数据,则epoll_wait会返回一次EPOLLIN
      可通过设置evnets为EPOLL_ET来显式设置工作模式为EPOLL_ET
    • 具体来说,设置ET之后,在epoll_wait()期间,若socket/pipe对应的缓冲区的数据发生了变化,则会通知事件发生,若没有变化,则会一直阻塞
    • 比LT高效的点在于epoll不会通过轮询文件描述符,观察缓冲区是否还有数据来通知事件是否发生,而是当数据到来时通过回调函数来通知事件发生
    • 当多块数据同时到来时,比如tcp的接收缓冲区小于发送的数据量,此时也会多次通知事件发生
    • ET对应的文件描述符应当是非阻塞的,使用EPOLLET标志的应用程序应该使用非阻塞文件描述符,以避免阻塞读或写使正在处理多个文件描述符的任务饥饿。使
    • EPOLLOUT设置ET的含义:如果溢出套接字发送缓冲区,则会从send或write获得EAGAIN。在这种情况下, 开始等待输出变得可能(内核排空套接字发送缓冲区,从而为发送更多数据腾出空间),方法是包含EPOLLOUT。

      如果多个线程(或者进程,如果子进程通过fork(2)继承了epoll文件描述符)在等待同一个epoll文件描述符上的epoll_wait(2)时被阻塞,并且兴趣列表中标记为边缘触发(EPOLLET)通知的文件描述符准备好了,只有一个线程(或进程)会从epoll_wait(2)中唤醒。这在某些情况下提供了一个有用的优化,可以避免”惊群”唤醒。

  4. 由于ET工作模式仍会导致某个事件被触发多次,使得多线程可能会处理同个fd,从而避免许多竞态条件,EPOLLONESHOT使得某个时刻只可能触发fd注册的一个可读,可写,异常事件,并且该事件只能触发一次

    • 可通过epoll_ctl来重新设置EPOLLONESHOT来重新触发新的可读事件(通过设置flag为EPOLL_CTL_MOD)
    • 监听fd不应该设为EPOLLONESHOT,不然只能某个时刻处理一个客户连接(新来的连接不会触发新事件)
  5. 多线程使用pthread_exit(NULL)来退出线程不是异步信号安全的,其发出的信号可以中断例如epoll_wait()等阻塞函数

  6. 对非阻塞IO进行connect()系统调用,可能会立即返回-1并设置errno为EAGAIN(UNIX本地协议族socket)或EINPROGRESS表示连接已经开始但未完全建立好,这时使用select或poll监听sockfd上的写事件(非阻塞connect连接成功或者因为超时失败时会触发写就绪事件), 超时失败是指客户端同步报文段发出后收不到ack,在本地arch环境下,重连次数为6,也就是127(1+2+4+8+16+32+64)s, 不论是失败或者成功,select都会返回写就绪,此时需要getsockopt中设置SO_ERROR选项来查看socket上的错误事件,对应测试cpp文件为non_blockconnect.cc
    Dan_Bernstein对于非阻塞socket连接的理解和思考

2. pthread - 符合POSIX标准的线程库

2.1. 线程的取消

  1. 如果想在线程执行时取消该线程。可以使用pthread_cancel(thread)来完成此操作,该函数向指定线程发出一个取消请求,。但是,请记住需要启用 pthread 取消支持。此外,还有取消时的清理代码。
1
2
3
4
5
6
7
8
thread_cleanup_push(my_thread_cleanup_handler, resources);
pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, 0);

static void my_thread_cleanup_handler(void *arg)
{
// free
// close, fclose
}
  1. 一个线程的取消类型,由**pthread_setcanceltype(3)**确定,可以是异步的(PTHREAD_CANCEL_ASYNCHRONOUS)或延迟的(PTHREAD_CANCEL_DEFERRED)(新线程的默认值)。异步可取消性意味着线程可以随时被取消(通常是立即,但系统不保证这一点)。延迟可取消性意味着取消将被延迟,直到线程下次调用一个是取消点(cancel_point)的函数。
  2. 线程的可取消状态由**pthread_setcancelstate(3)**确定,可以启用(新线程的默认值)或禁用。如果线程已禁用取消,则取消请求将保持排队状态,直到线程启用取消。如果线程已启用取消,则其可取消性类型确定何时发生取消。
  3. pthread_cleanup_push(void (*routine)(void *), void *arg);
    void pthread_cleanup_pop(int execute);
  • 这些函数操作调用线程的线程取消清理处理程序堆栈。清理处理程序是在取消线程时自动执行的函数。

  • pthread_cleanup_push()函数将routine推送到清理处理程序堆栈的顶部。当routine稍后被调用时,它将被给予arg作为其参数。

  • pthread_cleanup_pop()函数从清理处理程序堆栈的顶部移除routine,并在execute为非零时可选择执行它。

  • 在以下情况下,取消清理处理程序将从堆栈中弹出并执行:

    • 当线程被取消时,所有堆栈清理处理程序都会被弹出并按照它们被推入堆栈的顺序相反执行。

    • 当线程通过调用pthread_exit(3)终止时,所有清理处理程序都会按照前面描述的方式执行。(如果线程通过从线程开始函数返回来终止,则不会调用清理处理程序。)

    • 当线程调用pthread_cleanup_pop()并带有非零的execute参数时,最顶部的清理处理程序将被弹出并执行。

POSIX.1 允许 pthread_cleanup_push() 和 pthread_cleanup_pop() 作为宏实现,它们分别扩展为包含 ‘{‘ 和 ‘}’ 的文本。因此,调用者必须确保在同一函数中、在相同的词法嵌套级别内调用这些函数。(换句话说,仅在执行指定代码段期间才建立清理处理程序。)

  1. pthread_exit(void* retval) vs phread_cancel(tid)
    • pthread_cancel()函数将请求取消线程。目标线程的取消状态和类型决定何时取消生效。当取消被执行时,将调用线程的取消清理处理程序。当最后一个取消清理处理程序返回时,将为线程调用线程特定数据(Thread_specific data)析构函数。当最后一个析构函数返回时,线程将被终止。目标线程中的取消处理与从pthread_cancel()返回的调用线程异步运行
    • 由于许多系统调用在接收信号时返回并将errno设置为EINTR,因此很容易捕获此情况,并通过pthread_exit()使线程在此条件下干净地结束自身
      就线程资源清理的情况而言,二者并无本质区别, 都要通过pthread_cleanup_push设置的函数来清理动态分配资源

2.2. 线程的加入

int pthread_join(pthread_t thread, void **retval)

  1. 如果 retval 不为 NULL,则 pthread_join() 将目标线程的退出状态(即目标线程提供给 pthread_exit(3) 的值)复制到 retval 指向的位置。如果目标线程被取消,则在 retval 指向的位置放置 PTHREAD_CANCELED。
  2. 成功的话返回0,失败返回一个errno(例如因中断返回EINTR)

2.3. 一些关于编程的底层知识补充

  1. 函数的可重入性(reentrancy)
    函数的线程安全性,保证多个线程同时执行一个函数不会出现竞态条件,同时在单处理器系统上当该函数被中断(可能是信号导致的)并被重新执行时可以安全执行
  2. 函数的幂等性(idempotence)
    函数在被多次执行后可以产出同一结果

3. 并发编程

3.1. std::thread

  1. thread构造时会将可调用函数和传入参数完美转发并根据左值右值选择拷贝或移动构造至一个tuple中,返回invoke_result<decay::type, decay::type…>::type,也就是F调用去掉引用和cv限定之后的实参之后返回的类型,调用时会将tuple中的数据使用std::move方法转为右值进行调用,所以对于左值引用类型的数据无法正常调用,编译时thread会调用静态断言来报错不能将左值引用绑定到右值之类的消息

invoke_result 是 result_of 的 更进版本,具体内容见cppreference, 这里加入几个type_traits的知识点:首先decltype(function)不能直接用来调用,可以在后方加入&来表示对函数的引用:
int fn(int) {return int();}
typedef std::result_of<decltype(fn)&(int)>::type A;
其次常见的type_traits有

  • iterator_traits::type
  • remove_reference::type
  • decay::type
  • 函数declval()(返回右值引用,此函数只能在未求值的操作数(比如 sizeof 和 decltype 的操作数)中使用。)
  • is_constructible<T,args…>::value(args是否能构造T)等返回0或1来表示类型判断的类也属于type_traits,
  • plus:构建一个仿函数,使用operator()(T& lhs, T& rhs)可以计算任意两个T类型对象的和
  • 补充两个重要的工具类:

typedef std::integral_constant< bool, true> true_type; typedef std::integral_constant< bool, false> false_type;

  1. 不只是thread,bind以及async都会在创建的对象中构造传入实参另一份数据,所以如果函数形参为引用类型,使用std::ref()来构造reference_wrapper函数对象是一个明智的选择

  2. thread在析构时若是可结合的,比如没有显式join(主线程会阻塞直到thread对应的底层线程返回)或detach(底层软件线程分离)或者移动,则会调用std::terminate终止程序

  3. thread作为软件线程的句柄可调用native_handle来返回tid,之后可以通过其他方法来设置线程优先级和调度策略,这是future做不到的

  4. 为了防止线程终止,可以构造RAII类型对象,在析构时通过构造实参指定的join或者detach来指定析构方法

  5. 获取传统posix标准底层pthread的tid的方式为调用thread对象的native_handler, 而在c++层面上获取线程的ID标识符,本线程应该调用std::this_thread::get_id(),其余线程应该调用t.get_id(), t为std::thread对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    class ThreadRAII {
    public:
    enum class DtorAction { join, detach }; //跟之前一样

    ThreadRAII(std::thread&& t, DtorAction a) //跟之前一样
    : action(a), t(std::move(t)) {} //std::thread对象可能会在初始化结束后就立即执行函数了,所以在最后声明是一个好习惯

    ~ThreadRAII()
    {
    //跟之前一样
    }

    ThreadRAII(ThreadRAII&&) = default; //支持移动
    ThreadRAII& operator=(ThreadRAII&&) = default;

    std::thread& get() { return t; } //跟之前一样

    private: // as before
    DtorAction action;
    std::thread t;
    };

    按理来说当显式生成析构函数时,编译器不会再主动生成移动构造和移动赋值函数,但当对象是可以逐成员移动时,我们可以显式default声明移动函数来保证移动操作是可以被执行的

  6. 为了防止线程在开始后被修改优先级,可以在创建线程时将方法设为wait函数,将线程挂起

3.2. std::future

  1. 与thread不同,在使用默认策略启动时,不一定会使用异步执行,这种调用方式将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额异常的可能性
  2. std::launch::deferred启动策略意味着f仅当在std::async返回的future上调用get或者wait时才执行。这表示f推迟到存在这样的调用时才执行
  3. 默认启动策略的调度灵活性导致使用thread_local变量比较麻烦,因为这意味着如果f读写了线程本地存储(thread-local storage,TLS),不可能预测到哪个线程的变量被访问
  • 可以使用 fut.wait_for(0s) == std::future_status::deferred 检查函数是否被延时执行
  • 在检查异步执行的future是否完成执行时,可以检查fut.wait_for(100ms) == std::future_status::ready
  1. future是通信信道的一端,被调用者通过该信道将结果发送给调用者。被调用者(通常是异步执行)将计算结果写入通信信道中(通常通过std::promise对象),调用者使用future读取结果, 除了这种普通用途外还可以将promise模板实参设为void,用来当作条件变量通知future对象,future所在线程调用wait方法将会阻塞直到结果返回,这种方法好处在于不再需要互斥锁,条件变量,或者原子变量,并且可以用来设置线程优先级,使得线程在创建时不会自动运行,处于挂起状态,坏处在于通道的值只能设置一次,线程也只能被挂起一次。
    future信道
  2. 因为与被调用者关联的对象和与调用者关联的对象都不适合存储这个结果,所以调用结果必须存储在两者之外的位置。此位置称为共享状态(shared state)。共享状态通常是基于堆的对象,一般来说对于future对象拥有这个共享状态的控制权,或者说引用了共享状态,析构时引用该状态的future会自动阻塞并且等待任务完成
  3. future不能被拷贝,但他却可以用来构造shared_future(通过成员函数share()),之后转交共享状态的控制权,shared_future在并发访问时可以用来为每个线程构造副本来共享共享状态的拥有权,设置std::promise::set_value()可以在多线程使用wait来唤起多个线程
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    std::promise<void> p;                   //跟之前一样
    void detect() //现在针对多个反映线程
    {
    auto sf = p.get_future().share(); //sf的类型是std::shared_future<void>
    std::vector<std::thread> vt; //反应线程容器
    for (int i = 0; i < threadsToRun; ++i) {
    vt.emplace_back([sf]{ sf.wait(); //在sf的局部副本上wait;
    react(); }); //emplace_back见条款42
    }
    //如果这个“…”抛出异常,detect挂起!
    p.set_value(); //所有线程解除挂起

    for (auto& t : vt) { //使所有线程不可结合;
    t.join(); //“auto&”见条款2
    }
    }

3.3. condition_variable & mutex

  1. cv.Wait(lock, bool function)会导致当前线程阻塞,直到条件变量被通知或发生假唤醒,或者循环直到满足某个谓词

3.4. lock_guard & unique_lock

  1. lock_guard自动加锁解锁,在函数异常等情况下函数内部直接返回也会执行对锁的析构,需要注意对锁的占用时间,为了手动释放锁可以在lock_guard周围加入花括号即可
  2. wait function causes the current thread to block until the condition variable is notified or a spurious wakeup occurs, optionally looping until some predicate is satisfied

符合c++14标准的网络和高并发编程
http://example.com/2023/09/19/network-high-concurrency/
作者
李凯华
发布于
2023年9月19日
许可协议