多线程网络库开发笔记

Tags:

收到EPOLLOUT事件,但write时返回了EAGAIN?

这是因为EAGAIN不止是在发送缓冲区满时返回,还可能是未ACK的包数量已经达到了拥塞窗口的大小

而EPOLLOUT并不会检查拥塞窗口的情况,只要发送缓冲区不满,就返回EPOLLOUT了。

用并发压力测试可以测出这种情况:在epoll_wait返回后,检测可写事件的fd是否发送缓冲区不满,不满的话尝试写入一个字节,然后通过write的返回值和错误码就可以知道有没出现缓冲区不满的EAGAIN。同时打印出拥塞窗口信息,就可以看出EAGAIN原因:

22.png

参考资料:https://stackoverflow.com/questions/3070127/will-eagain-return-on-send-for-anything-other-than-buffer-full

https://linuxgazette.net/136/pfeiffer.html

EAGAIN/EWOULDBLOCK can also be returned (for TCP sockets) when the number of unacknowledged packets has reached the congestion window.

To check the status of the socket w.r.t. the congestion window, then try this:

#include <netinet/tcp.h>
static void print_tcp_cwnd(int socket)
{
    struct tcp_info tcp_info;
    uint tcp_info_length = sizeof(tcp_info);
    if ( getsockopt( socket, SOL_TCP, TCP_INFO, (void *)&tcp_info, &tcp_info_length ) == 0 ) 
    {
        printf("tcpi_snd_cwnd: %u, tcpi_unacked: %u\n",
            tcp_info.tcpi_snd_cwnd,
            tcp_info.tcpi_unacked
           );
    }
}

If tcpi_unacked == tcpi_snd_cwnd then send() will return EAGAIN/EWOULDBLOCK for a non-blocking socket.

write无限返回EAGAIN导致的cpu超载和FIN_WAIT1卡死

这是只会出现在非阻塞socket的问题。

解决思路:

  • 检查是不是意外调用了onWritable接口,即还没有监听可写事件,却调用了可写回调接口

  • 尝试用while循环来调用send data,直到send发完数据,或返回-1(EAGAIN)并添加可写监听。

另外,收到可写事件时,尝试write剩余字节,如果write能把所有字节都写进发送队列,那么就可以删除EPOLLOUT监听了。

atomic变量也会segment fault?

#0  0x00000000004cb56b in std::atomic<wynet::TcpConnection::State>::load (this=0x58, 
    _m=std::memory_order_seq_cst) at /usr/include/c++/4.8.2/atomic:209
#1  0x00000000004c9a6d in std::atomic<wynet::TcpConnection::State>::operator wynet::TcpConnection::State
    (this=0x58) at /usr/include/c++/4.8.2/atomic:176
#2  0x00000000004f0fa1 in wynet::TcpConnection::shutdown (this=0x0)
    at /root/github/wynet/src/connection.cpp:74

gdb查core文件,发现日志很奇怪,在atomic的load里crash了。

最终发现是访问了一个null的shared_ptr的atomic变量。加if判断即可。

10k和10k+连接问题

10k数量级的网络连接,是非常容易暴露出自己写的网络代码的问题的。

局域网下有2种测试方法:

  1. server和clients同主机(走回环,不需要经过网卡)

  2. server和clients不同主机,clients可以分散到多台主机(需要经过网卡)

我现在做的第一种,本地开2个进程,一个进程跑server,另一个进程发起10k+个客户端连接,pingpong发消息,可以测吞吐量并且也可以测下承载能力。

目前遇到很多问题,逐个列举下:

  • 打开文件描述符数量限制。可以用ulimit -n修改。Mac下有些特殊。

  • 客户端的port不够用。

先查看系统上限:cat /proc/sys/net/ipv4/ip_local_port_range,可能会输出32768 60999,只有28231个端口可用。 但由于port是双字节,理论上撑死只有3万个port可以用。改大这个范围的意义不大。此时要考虑多主机来测试30k+连接了。

多线程:wait morphing

以pthread为例,在C++中使用pthread的pthread_mutex_xxx和pthread_cond_xxx,实现RAII的mutex guard类以及阻塞队列(BlockingQueue)时,会遇到wait morphing的需求。

例如,一般会先实现2个类:MutexLock,和MutexLockGuard:

class MutexLock;

class MutexLockGuard {
    MutexLock &m_mutex;
public:
    MutexLockGuard(MutexLock &mutex) : m_mutex(mutex)
    {
        m_mutex.lock();
    }

    ~MutexLockGuard()
    {
        m_mutex.unlock();
    }
};

然后是BlockingQueue:

template <typename T>
class BlockingQueue
{
public:
    void put(const T &x)
    {
        MutexLockGuard lock(m_mutex);
        m_queue.push_back(x);
        m_notEmpty.notify(); // wait morphing
        // 注意,lock是在退出本函数时才销毁,所以顺序是:cond signal -> unlock
    }

    T pop()
    {
        MutexLockGuard lock(m_mutex);
        while (m_queue.empty())
        {
            m_notEmpty.wait();
        }
        T front(std::move(m_queue.front()));
        m_queue.pop_front();
        return front;
    }
};

调用BlockingQueue的put函数,做了几个事情:

  1. lock了mutex
  2. put一个对象到队列
  3. 发射条件变量信号
  4. 函数退出,unlock了mutex

MutexLockGuard对mutex lock/unlock的封装,是非常符合RAII的,只需要在函数开头声明一个栈变量,就可以保证成对的lock、unlock。

然而,这个特性遇到条件变量时,就不是很好了。首先,因为unlock和notify是2个单独的操作,谁先谁后,效果并不一样。以上面的put函数为例,put完一个对象后,做2种情景假设。

情景1:

  1. 线程1先unlock了mutex
  2. 线程1调用notify,唤醒等待线程2
  3. 等待线程2被唤醒,因为mutex已经unlock,于是立即就lock到了mutex

情景2:

  1. 线程1先调用notify,唤醒等待线程2
  2. 等待线程2被唤醒,试图锁住mutex,但mutex还未解锁,于是又进入睡眠。
  3. 线程1unlock了mutex
  4. 线程2因为在第2步中试图锁住mutex,所以会被第3步的unlock唤醒,尝试第2次加锁,lock到了mutex

分析可见,情景2中,线程2被唤醒了2次才锁到了mutex,有多余的性能开销。

而我们的C++ RAII MutexLockGuard和BlockingQueue就是情景2.

linux系统有专门针对这个问题的优化技术:wait morphing。

wait morphing含义是,系统可以知道情景2的第2步的该次唤醒并不能锁住mutex,那么把挂在该condvar的所有等待线程,转移(morphing)到mutex的等待线程队列,并不去唤醒它们,从而免去多余的上下文切换

当然这个优化是内核的,需要先了解你的linux有没实现这个优化。

tcp连接管理问题

Mac下意外的RST:

出现情景:

  1. 客户端发送了一些数据包后,调用close或者shutdown(SHUT_WR),此时客户端会发送FIN
  2. 服务端收到FIN,如果没有待发送的数据,那么返回FIN+ACK,如果还有数据要发送,那么只返回ACK
  3. 客户端收到自己发出的FIN的ACK
  4. 服务端如果仅返回了对客户端FIN的ACK,然后继续发送剩下的数据,并会在最后发一个FIN
  5. 重点,如果客户端没有接收完服务端剩下的数据,就结束进程(会彻底关闭socket),那么客户端协议栈收到服务端余下的数据包时,只能返回RST,因为socket已经不见了

结论:在Mac下,客户端进程需要稍微延时关闭,例如在main最后一行加sleep(1)。

shared_ptr与多线程安全

线程安全要点:

  1. 因为shared_ptr内部维护了2个指针,1个指向真实的对象,一个指向control block,所以修改shared_ptr时需要两步操作,于是就有了race condition问题。
  2. 在两步操作中,shared_ptr修改control block是保证线程安全的,即ref count不会出问题,但修改对象指针则不是。
  3. 多线程只读shared_ptr仍然是安全的。
  4. 多线程读写shared_ptr不安全,其中不安全表现之一是出现空悬指针。在这种情况下, 读写shared_ptr必须用mutex保护。

shared_ptr细节特点:

  1. shared_ptr有ptr指针,control block里还有一个ptr指针:这是为了让shared_ptr无需虚析构函数(virtual destructor),第一次构造shared_ptr时,就把目标对象的指针放进了control block的ptr里,从而记住类型,而shared_ptr的ptr则可以是对象类型也可以是父类型,并不影响计数降为0时的自动析构。

  2. 基于特点1,于是可以实现更粗暴的多态,即任意shared_ptr都可以转成shared_ptr。可以用来防止对象过早析构,或者做泛型编程。

  3. 应尽量用make_shared,可以节约一次new操作,不过需要看构造函数的访问性,不一定可以替代new T。

weak_ptr:weak_ptr可以提升为shared_ptr,提升操作lock()据说是线程安全的。

self connect 自连接问题

资料:http://sgros.blogspot.jp/2013/08/tcp-client-self-connect.html

就是客户端socket连接成功后,发现两端ip和port一模一样。

解释:

  1. 客户端发起连接时,只知道对端的ip和port,以及自己的ip(localhost),但自己的port是操作系统随机分配的。
  2. 每一个tcp连接都是由四元组(source IP, source port, destination IP, destination port)唯一标识的。
  3. source port可以认为是随机分配,但linux可能会先顺序地分配用户有方法自己决定port,用bind,但一般很少这样做。
  4. 这个source port也叫Ephemeral port(转瞬即逝的端口),这些port是在某个范围段里选的,linux下调用cat /proc/sys/net/ipv4/ip_local_port_range可以查这个范围,会返回2个数字,例如 32768 60999
  5. self connect只出现在本机客户端连本机服务器的情景下,这是因为source IP和destination IP要一致。
  6. 复现self connect的方法是,客户端connect本机ip_local_port_range里的某个端口,因为该端口并没有socket服务器在监听,所以一般情况下会返回RST。随着不断发起这些不可能成功的连接,可能会出现操作系统分配了一个和dest port一样的source port,于是self connect出现。
  7. 之所以允许这种情况,是因为tcp标准里有simultaneous open 同时打开这个概念。
  8. self connect可以成功,意味着“两端”之间完成了三次握手,进入了ESTABLISHED状态。
  9. self connect只可能发生在握手阶段,所以对于一个已经ESTABLISHED的socket,也无法利用self connect做什么坏事的。

接着,剖析下当分配了和dest port一样的source port时,tcp状态机究竟是怎么进入ESTABLISHED的:

  1. 首先socket是CLOSED状态
  2. 调用connect,发送SYN,进入SYN SENT状态
  3. 这个socket马上又收到来自自己的SYN包,于是根据tcp状态机图所示,该socket状态机会走simultaneous open路径,发送SYN+ACK,并进入SYN RECEIVED状态
  4. 因为第3步里面发送了ACK的,所以这个处于SYN RECEIVED状态的socket接着会收到ACK,根据状态机图,socket就进入了ESTABLISHED

总结:避免self connect是最佳做法,也就是不要选择Ephemeral port作为server端的监听端口,就没事了。

wait-free V.S. lock-free

在wiki上这2个东西都是指Non-blocking algorithm

非阻塞

一个算法被称作非阻塞的前提是:线程的失败或挂起(failure or suspension),不会导致其他线程的失败或挂起。

lock-free:

在系统级别保证该系统(即用户程序)总是有进展的(progress)。换句话说,如果该系统的所有线程运行足够长时间,能保证至少有一个线程取得进展(make progress),就是lock-free。

在lock-free中,如果一个线程被挂起,其他线程依然能取得进展。lock-free优点在于CPU是可以一直繁忙的,当当前线程被挂起,CPU可以接着处理别的线程(没有核心处于空闲状态),因此增加了系统的吞吐量。但不足之处是,还是可能存在一些线程是被延迟处理的(waiting),也就意味着这些线程的工作有延时。

在lock-free系统中优化延时的办法是,建立调度器,维护一个较好的平均延时。

wait-free:

和lock-free的区别是,wait-free是在lock-free的前提上,进一步要求该系统的线程的操作在有限步骤内能保证完成。所以wait-free必然满足lock-free。

就上面说的吞吐量而言,wait-free更佳,因为保证了每个线程只要有机会被CPU载入执行,就总是能在有限步内完成,没有等待延时,no waiting。例如实时交易系统就需要wait-free。

Q&A

Q:为什么lock-free不等于wait-free?

A:假设一个情景,系统运行在n核环境,并且有n个线程在做一个长操作,其中有m个(m<n)线程能在有限步内完成操作,其他的n - m线程可能会操作失败(fail)并一直不断重试(retry on failure)(失败原因可能那n个线程有关),n-m线程是不能保证在有限步骤内完成操作的(也就是需要wait),所以这种系统就只是lock-free而已。而如果换作wait-free的话,每个线程都能保证操作能在有限步以内完成,并且每个线程和其他线程相互独立,没有依赖,就是无等待,即wait-free。

Q:lock-free是不是就是无锁?

A:确实是要求无锁。因为如果系统内有一个线程获得了锁,然后万一线程异常了没有释放锁(无法保证progress),就会导致等待该线程的其他线程永久饥饿。如果这个锁释不释放对其他线程无所谓,那这个锁也显然无意义。综上,lock-free必然要求无锁。

wait-free也是无锁?

A:wait-free是比lock-free更进一步的东西,当然也得是无锁。

ABA problem

指令重排和thread fence

指令重排:

(参考资料:http://preshing.com/20120625/memory-ordering-at-compile-time/)

编译器为了优化性能,可能会按和c/c++代码不一样的顺序重新排列指令。

指令重排需要打开编译优化选项。

指令重排保证对单线程程序没有影响。但对多线程程序来说,就惨了。

例子(Linux 3.10.0-514.26.2.el7.x86_64,gcc 4.8.5):

int A, B;

void foo()
{
    A = B + 1;
    B = 0;
}

执行 gcc -S -masm=intel test.c,得到:

    mov eax, DWORD PTR B[rip]   // 取出B值并写入eax
    add eax, 1                  // eax = eax + 1
    mov DWORD PTR A[rip], eax   // 把eax写入A
    mov DWORD PTR B[rip], 0     // B = 0

执行 gcc -S -masm=intel -O2 test.c,得到:

    mov eax, DWORD PTR B[rip]   // 取出B值并写入eax
    mov DWORD PTR B[rip], 0     // B = 0
    add eax, 1                  // eax = eax + 1
    mov DWORD PTR A[rip], eax   // 把eax写入A

显然,两者区别是第四行B=0指令被提前到第二行了,即B=0发生在对A的赋值之前,和c代码是不同的顺序。

单线程程序不会感知到这个区别。但考虑在多线程环境下,就容易引发一些问题。

一是影响到了lock-free代码,考虑下面的代码,用了一个共享变量IsPublished来标志Value是否有数据:

int Value;
int IsPublished = 0;

void sendValue(int x)
{
    Value = x;
    IsPublished = 1;
}

如果编译器重排了指令,使得IsPublished=1(Store)发生在Value = x(Store)之前。如果有一个线程在这2次store之间抢占了CPU,它看到IsPublished为1,但其实Value是还未赋值的,就引发了错误。

如果不想受到重排指令的危害,那就得考虑使用thread fence了。

memory_order(访存次序)和thread fence

中文:

http://zh.cppreference.com/w/cpp/atomic/memory_order

英文:

http://en.cppreference.com/w/cpp/atomic/memory_order

分成三大类:

一. 顺序一致性模型:

  • memory_order_seq_cst:

二. relax模型:

  • memory_order_relaxed:没有顺序限制,仅保证该操作的原子性。

三. Acquire-Release模型:

  • memory_order_consume:
  • memory_order_acquire:
  • memory_order_release:
  • memory_order_acq_rel:

cache line 和 cache line bouncing

little's law 律特定律

consistent hashing 一致性哈希

参考代码(python):https://github.com/goller/hashring

参考代码(go):https://godoc.org/stathat.com/c/consistent#example-New

https://github.com/ioriiod0/consistent_hash

(未经授权禁止转载)
Written on March 29, 2018

博主将十分感谢对本文章的任意金额的打赏^_^