UNIX网络编程从零起步(7): 讨论

前面六个小结文章基本上涵盖了UNIX网络编程(只针对TCP协议)的基本要点. 尽管所给的例程都可以编译运行, 然而它们仅仅只是个雏形, 和真正能够部署使用还有很长的距离.

高性能

我们在第六小结使用epoll和kqueue实现了非阻塞的服务器端, 并声称其性能最佳. 然而真正要让它实际运行的性能最佳, 我们需要精细的调整它的运行参数. 这里我们说的性能包括两个方面: 能够同时快速处理大量连接(high performance); 有很强的可扩展性(scalability), 例如当服务器CPU/core增加时程序性能也成正比增加.

线程VS进程

我们在5, 6两个小结都用到了pre-threaded的手段来消除创建线程的延迟. 既然延迟已经被消除, 那么是不是用进程也可以?确实可以用进程, 实际上像目前非常流行的nginx这样的轻量级httpd服务器使用的就是多进程而非多线程. 用多进程最明显的好处就是每个进程相互独立, 当出现突发因素导致某个工作进程异常退出时, 不会影响到其他工作线程. 如果对服务器程序的稳定性有严格的要求, 使用多进程给编程带来的负担要小一些. 提供http服务需要处理各种复杂的事务而事务之间少有进程间通讯, 也难怪主流的web服务器如apache和nginx都采用多进程. 多线程的好处是线程间通讯方便. 我们打开日志文件虽然使用了O_APPEND标志保证写入的原子性, 但是如果要添加日志回滚(删除或者重命名旧的日志文件)功能且保证日志不会丢失, 这就必然会涉及到工作任务之间同步的问题. 这是我使用多线程的原因.

多少个线程

到写这篇文章的2013年, 摩尔定律仍然成立, CPU芯片上得晶体管数量依旧成倍增长. 尽管CPU的主频较之10年前几乎没有变化, 但是一块芯片上包含了更多的核. 个人PC上四核已成标配, 服务器上拥有两颗八核CPU也很寻常. 多线程的目的就是为了充分利用这些核心来达到更好的性能. 运行我们程序的操作系统是通用分时(time-shared)系统, 所以开启线程的数目将直接影响到程序的性能. 和GPU的构架不同, CPU通用寄存器数目很少, 即便是线程其正文切换(context switch)开销也很可观. 开启过多线程, 大量计算时间消耗在正文切换上, 反倒降低了程序的性能. 显然, 程序所在主机上有多少个CPU核(intel的超线程不计算在内)就开启多少个线程是最好的选择.

操作系统上并非只运行我们的程序, 线程切换无法避免. 线程切回运行时, 在默认设置下系统不保证线程仍在上次运行时所在的CPU/core上运行. 这样设置的道理是操作系统无法获知程序运行多长时间. 假设在一台双核主机上, 同时A, B和C3个进程, A和B是后台守护进程, C是一个只运行几分钟的进程. 由于操作系统不知道它们的确切运行时间, 有可能将A和B分配到一个core上, C分配到另一个core上. 如果操作系统默认core与进程绑定, 那么几分钟之后主机CPU上的一个core处于繁忙状态, 而另一个core则处于闲置状态. 显然这种进程调度不是有效的, 操作系统默认不将core与进程绑定的原因也在于此.

现代的CPU都有缓存, 如果仍在原来的core上运行, 只需从L1或者L2缓存上读取线程正文. 但是如果更换运行core, 那就需要通过多核共享的L3缓存读取正文, 从而降低程序性能. 如果主机上有多个CPU, 恢复后再另外一个CPU的某个core上运行, 则要走主板总线, 情况会更加糟糕. 为了获得更高的性能, 我们还需要将工作线程跟core绑定. Linux提供shed_setaffinity和pthread_setaffinity_np来将线程和core绑定, BSD系列UNIX目前还没这样的接口.

负载均衡

为了最大限度的利用服务器计算机CPU, 服务器程序还需要能将客户发起的连接均匀的分配到工作线程上. 在非阻塞的server5.c的程序中, 当lfd为可读时, 其中一个工作线程会循环调用accept直到返回-1并且errno为EAGAIN为止. 相比于只调用一次accept, 这样做的好处是当发起大量连接时, 减少epoll_wait的调用次数和缩短服务器响应时间. 从短时间片上来说可能会导致某个工作线程比其他线程要繁忙, 但是从长时间间隔上来说工作线程之间的负载大致相当.

惊群与加锁

在server5.c中, 为了防止多个线程同时accept一个lfd, 在accept前后用了互斥锁. 多线程编程为了提高程序速度, 往往对线程锁的使用格外小心, 甚至还会设计一些无锁的数据结构. 我们的程序仍然使用互斥锁, 原因是: 我们开启的线程的数目并不多, 多个线程竞争同一个锁的情况并不多; 当线程处理并发连接数目很多时, 在一个循环内, 大部分的时间用在处理连接上, 加锁解锁的时间只占其中很小的一部分; 此外, 多个线程同事accept一个lfd, 虽然现代很多操作系统都会让其中一个返回成功, 其他失败(非阻塞)或者等待, 但是这只不过是把把矛盾转嫁到内核里, 相比于使用汇编原子操作实现的用户态锁(例如Linux的futex), 性能反而大大不如.

此条目发表在计算机与网络技术分类目录,贴了, , 标签。将固定链接加入收藏夹。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

This site uses Akismet to reduce spam. Learn how your comment data is processed.