多进程网络服务

背景

近来在优化一个java项目的性能,在服务架构、gc、代码实现方式都做了基本的优化后,思考如何对其进行更进一步的优化。进一步的优化有两个方向:

  1. 使工程本身(架构、gc、代码)再进一步。
  2. 验证类似nginx一样的多进程网络服务是否可行。较容易实现,且很容易应用到其它线上服务上。

选择哪种方式,需要在业务要求、收益、成本之间做出平衡。

实现

多进程网络服务是指一台服务器上监听相同端口号的多个进程组成的一组服务。

多进程监听相同端口号有两种方法:

  1. socket句柄重用
  2. 端口重用

socket句柄重用

socket会话流程图

其中int sockfd = socket(domain, type, protocol) 会返回一个整数,然后通过fork()来创建子进程就可以达到多进程共用同一个socket句柄。这样就能实现多个进程监听同一个端口的效果。

这种方法会导致惊群问题。当Client调用connect() 与Server建立连接时,Server所有的进程都会被唤醒,但是只有一个进程能成功建立连接,其它进程然后继续休眠。accept()epoll_wait()系统调用都会导致惊群问题,不过在linux2.6内核之后,accept()不会再导致惊群问题了。nginx中解决问题是通过在进程间加锁,进程间加锁有两种方法:共享内存和文件锁。

引申话题:进程间通信方式都有哪些?

jvm不支持fork,负作用很多,垃圾回收器和jit编译器的内存部分在fork之后会发生变动,共享内存也会被分离。所以如果java要想以这种方式来监听同一个端口非常麻烦。但是仍有方法来实现,大致过程如下:

  1. 开启一个进程创建socket
  2. 获取socket句柄的整数值fd
  3. 在这个进程中直接开启其它进程(不是通过fork方式)
  4. 将fd以参数传递给子进程
  5. 以fd重新构造ServerSocket

不过这个过程需要用到java的反射机制,在java1.9之后好像因为安全原因不能再使用这种反射方式了。相关的开源库 Multi-process network server

端口复用 SO_REUSEPORT

Linux内核(>= 3.9)支持SO_REUSEPORT特性,不过之前的内核版本也有通过打补丁的方式支持这个特性。java是从java9之后支持的。

TCP/UDP连接是通过一个五元组来唯一标示的:{<protocol>, <src addr>, <src port>, <dest addr>, <dest port>}。默认情况下,操作系统中不允许重复绑定源地址和源端口。启动服务有时就会遇到address in use的异常,就是这个原因。

但是通过给setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &reuse_port, sizeof(reuse_port)); 就可以复用端口,重复的绑定source_addr, source_port,只通过给socket设置一个参数就可以让多个进程监听同一个端口或者同一个进程中开启多个相同的serversocket。

但是这个选项要求所有的连接都必须设置SO_REUSEPORT选项,如果前面的socket没设置,后面再监听这个端口的时候就会报错。

来自于这篇文章的demo:

server.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import socket
import time
import os

PORT = 10002
BUFSIZE = 1024

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
s.bind(('', PORT))
s.listen(1)

while True:
conn, addr = s.accept()
data = conn.recv(PORT)
conn.send(b'server[%s] time %s\n' % (os.getpid(), time.ctime()))
conn.close()

s.close()

模拟客户端请求十次

1
for i in {1..10};do echo "hello" | nc 127.0.0.1 10002;done

无侵入实现端口复用

已有的程序或者引用别人写的库时不方便修改代码或者不能修改代码时,就需要另外一种方式来实现。操作系统在执行系统调用时,会去寻找对应的.so库,在这个过程中可以执行拦截步骤。让系统先找到我们自己写的.so库,从而替换同名系统函数。通过LD_PRELOAD来进行拦截。

有人已经这个方式实现了一个,github地址。使用起来也非常简单,使用方式:

1
2
3
4
5
6
7
8
9
10
11
git clone https://github.com/yongboy/bindp.git
cd bindp
make
# 编译成功之后,在当前目录会出现一个libindp.so文件

# 删掉server.py文件中 这行代码 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
# 还是在当前目录
REUSE_PORT=1 LD_PRELOAD=./libindp.so python server.py &
REUSE_PORT=1 LD_PRELOAD=./libindp.so python server.py &

lsof -i :10002 # 确认已经成功启动了两个进程

注意:SO_REUSEPORT在mac上的表现形式和linux上不一致

SO_REUSEADDR

这个socket选项虽然字面意思也表示是重用地址,但它主要是用在服务重启的时候。当关闭服务时,连接就会处于tcp中的time_wait状态,如果不设置这个选项是不能成功启动服务的。

SO_REUSEPORT详细对比可以参考stackoverflow的这篇回答,回答的非常好。下面的表格是引用这篇回答的。

SO_REUSEADDR       socketA        socketB       Result
---------------------------------------------------------------------
  ON/OFF       192.168.0.1:21   192.168.0.1:21    Error (EADDRINUSE)
  ON/OFF       192.168.0.1:21      10.0.0.1:21    OK
  ON/OFF          10.0.0.1:21   192.168.0.1:21    OK
   OFF             0.0.0.0:21   192.168.1.0:21    Error (EADDRINUSE)
   OFF         192.168.1.0:21       0.0.0.0:21    Error (EADDRINUSE)
   ON              0.0.0.0:21   192.168.1.0:21    OK
   ON          192.168.1.0:21       0.0.0.0:21    OK
  ON/OFF           0.0.0.0:21       0.0.0.0:21    Error (EADDRINUSE)

句柄复用与端口复用的对比

在使用上明显是端口复用更加方便,甚至不修改任何代码就可以达到目的。由于这两种方法的原理不同,也导致了它们在实际应用上对性能的影响也不一样。

它们处理连接的方式如下图:

从图中可以看出句柄复用模式只有一个 listen socket (就是父进程创建的serversocket)来通知woker进程有连接来了,然后worker进程会产生竞争,最终只有一个worker进程能获取这个连接。优缺点:

  • 优点 如果其中一个进程发生了堵塞,新的请求会分发到其它worker进程上,不会造成后续请求的堵塞
  • 缺点 竞争带来额外消耗;有可能大部分请求都被一个worker进程获取到,造成进程间的负载不均衡

端口复用模式中,每个worker进程都会绑定一个serversocket,由内核来决定哪个worker进程可以接受新连接。内核的分发方式是循环分发的,即按顺序分发,非常均衡。

  • 优点 减少了worker进程间的竞争,提升性能
  • 缺点 如果一个worker进程发生了堵塞,那后续分发到这个worker进程的请求都不能被及时处理

两种方式各有优劣,实际场景中选择哪种模式应该结合具体业务来,模拟真实环境做个压测来比较一下。

更详细的对比可参考这两篇文章:

  1. Socket Sharding in NGINX
  2. Why does one NGINX worker take all the load?

引申问题

判定一个服务否适合使用这种方法

jvm进程一般都是多线程服务,如果这个时候再引入多进程模型的话,是否能提升性能,性能有提升又是为什么?

在我优化的这个案例中,性能是有提升的,而且提升的非常明显。我认为主要原因有以下几点:

  1. 由于cms垃圾回收器的机制,当进程占用的内存过大时,会导致gc时间增长。多进程能够缓解这个不足。
  2. 多线程势必要带来资源竞争造成的消耗,而并发越大竞争也可能越激烈,并且由于服务开发者的水平和业务的复杂度原因,很难去将这个消耗完全消除,而且要消除需要的成本也会非常大。多线程可以平分一台机器上的流量,就会变相的降低单进程内的竞争程度,因此会提升性能。

进程/线程调度

由于这是多进程服务,需要考虑多进程和多线程在调度上的消耗。多进程是否会增加额外的消耗?

线程上下文切换成本。同一个进程的上下文进行切换比不同进程的上下文进行切换消耗小,为什么?

是否核越多服务的性能就越高?参考这个案例:fastsocket笔记

进程间通信方式都有哪些

共享内存、管道、文件、网络、信号、信号量、消息队列。

参考文章

为什么nginx中一个进程中只有一个线程

线程、进程的定义,多线程、多进程区别。


参考

  1. linux core
  2. socket选项 SO_REUSEPORT
  3. fastsocket笔记
  4. nginx architecture
  5. Why does one NGINX worker take all the load?
  6. SO_REUSEPORT
  7. fastsocket
  8. why jvm does not support fork
  9. Thread Affinity
  10. 进程调度
  11. nginx 文件锁、自旋锁的实现
  12. Linux 共享内存以及 nginx 中的实现

评论