Bryton's Blog

Bryton Lee 的技术博客

[Java] Netty误用导致JVM Crash问题排查

前两天一位同事给我留言,有一个应用在搬机房之后,集群之中有几台机器出现JVM crash的问题。JVM crash之后留下的信息很少,跟我们平常看到的JVM crash现象有较大的区别。 没有hs_err_xxx.log,也没有OOM-killer的日志。

故障现象:

接到这个Case之后,我登陆到机器上去查看相关信息,这个时候JVM进程已经停止,日志文件里面没有OutOfMemoryException, /var/log/kern和/var/log/message也没有OOM-Killer日志。 没有JVM crash log。 只有Jboss日志里面run.sh输出一句xxxpid 已杀死。

通过tsar历史数据可以看到JVM在crash这一刻,系统内存使用接近100%, JVM进程crash之后,系统内存的使用率回落到5%左右。这样的内存使用情况肯定是不正常的。由于JVM进程已经crash,留下的信息又很有限,问题不能定位。所幸的是,这个问题能稳定重现,于是我们给这台机器单独加上监控,在系统内存使用到80%的时候报警,时间的选择我觉得是比较重要的,内存使用80%的时候,基本上能说明问题,但是如果选择90%以上,可用的内存很少,登陆到机器之后,能做的事情也是有限的,而且JVM进程也随时可能挂掉,能处理的时间也很少。

添加监控之后,启动JVM进程,等待问题重现,大约花了6个小时的系统内存的使用达到80%。于是有了下面的排查过程。

排查过程:

排查的过程,我觉得很重要的是前期信息收集,信息的收集比较全面,对于问题的定位和深入是有好处的。我们需要一把“尺子”去衡量如何搜集,我的观点就是以资源的角度去搜集,系统能够提供给资源有:CPU,内存,网络。登陆到机器之后,我们先看下top的情况:

$top

top - 13:14:34 up 27 days, 19:39,  3 users,  load average: 1.49, 2.01, 2.04
Tasks:  32 total,   1 running,  31 sleeping,   0 stopped,   0 zombie
Cpu(s): 15.2%us,  3.2%sy,  0.0%ni, 80.8%id,  0.0%wa,  0.0%hi,  0.5%si,  0.2%st
Mem:   8388608k total,  8388516k used,       92k free,        0k buffers
Swap:  1999864k total,        0k used,  1999864k free,  1411772k cached

PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+     COMMAND
143748 admin     20   0 12.5g 6.2g  12m S 82.1 77.9 203:07.45 java

系统内存8G, CPU 4执行线程(top命令下按下”1”能显示CPU的执行线程数,上图没有显示是因为我知道机器的配置),Java进程143748物理内存(RES)使用6.2G, 系统的load1是1.49, Java进程CPU使用率是82.1%。这些数据看下来,能得出的结论是Java进程143748消耗的主要是内存,不是CPU,以往碰到的case当中,有些Java程序的Bug,导致了死循环,这个时候的CPU利用率很高,我们的思路是找出什么原因导致的CPU利用率很高。这个case显然不是,所以问题的核心是要找出什么原因导致的内存使用很多,避免了其他方向的查询而浪费了宝贵的故障排查时间和机会。

遇到Java内存使用的问题,最先想到的是要看下Java Heap的配置和gc的情况。JVM的启动参数如下:

143748 ?        Sl   207:03   /opt/java/bin/java -Dprogram.name=run.sh -server -Xms4g -Xmx4g -XX:PermSize=96m -XX:MaxPermSize=384m -Xmn2g -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSMaxAbortablePrecleanTime=5000 -XX:+CMSClassUnloadingEnabled -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=80 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/admin/logs/java.hprof -verbose:gc -Xloggc:/home/admin/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Djava.awt.headless=true -Dsun.net.client.defaultConnectTimeout=10000 -Dsun.net.client.defaultReadTimeout=30000 -Djava.net.preferIPv4Stack=true -Djava.endorsed.dirs=/opt/jboss/lib/endorsed -classpath XXXXX.....

通过JVM的启动参数,我们知道Java Heap空间使用4G,接着通过jstat命令查看下Java Heap空间中内存使用和GC的情况:

$sudo -u admin /opt/java/bin/jstat -gcutil 143748 1000 100
  S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
18.67   0.00  26.64  37.49  55.88   1102   46.349     8    8.238   54.587
18.67   0.00  33.75  37.49  55.88   1102   46.349     8    8.238   54.587
18.67   0.00  38.47  37.49  55.88   1102   46.349     8    8.238   54.587
18.67   0.00  45.10  37.49  55.88   1102   46.349     8    8.238   54.587
18.67   0.00  52.19  37.49  55.88   1102   46.349     8    8.238   54.587
18.67   0.00  57.73  37.49  55.88   1102   46.349     8    8.238   54.587
18.67   0.00  63.58  37.49  55.88   1102   46.349     8    8.238   54.587
18.67   0.00  69.24  37.49  55.88   1102   46.349     8    8.238   54.587
18.67   0.00  75.43  37.49  55.88   1102   46.349     8    8.238   54.587
........

Java Heap无论是哪个区的内存使用都没有超过60%,Full GC的次数也很少,只有8次,说明Java Heap本身的内存使用是正常的,4G的Java Heap空间和6.2G的Java进程内存实际占用差了2G+, 这部分的内存很大一部分肯定是不正常的,这部分内存就是俗称的“堆外内存”,其实这种说法是有误的,从进程的角度看,程序分配内存主要的来源是Stack和Heap, Java Heap只是进程Heap中的一部分,如果Java Heap之外分配内存就称之为“堆外内存”是有误的, 我觉得称之为“Java堆外内存”可能比较合适。

Java堆外内存分配很多,google一下查能看到很多Native I/O使用DirectByteBuffer问题,由于DirectByteBuffer内存分配不在Java Heap中,而是在一些Native的方法或者操作系统相关代码分配的,所以这些内存的分配不会统计到Java Heap中。 DirectByteBuffer分配的内存什么时候销毁并不简单。如果DirectByteBuffer的实例已经没有引用(reference),在Hotspot版本小于20.7的JVM上由于Hotspot的Bug回收不掉; 如果DirectByteBuffer实例晋升到了old区,只能等到Full GC才能清理掉,问题是Full GC的触发需要等到Java Heap的内存使用紧张的时候才会触发,DirectByteBuffer实例本身占用Java Heap的内存较少,但是Java Heap之外的内存可能已经申请很大。

第一个问题我在处理的时候没有发现,事后分析才知道还有这点遗漏,所以没有排查。不过我也确认过,线上的JDK没有这个BUG。关于第二个问题,如果是DirectByteBuffer实例晋升到了Old区,Java heap本身比较空闲,不会触发Full GC导致的堆外内存泄漏,如果有办法显式的触发JVM做Full GC,然后我们观察内存的使用情况,就能排除是否与第二个问题有关。jmap这个命令能做到触发JVM Full GC。命令如下:

jmap -histo:live pid

jmap命令执行了几次之后,发现内存的使用并没有下降,说明不是这个问题,虽然如此,依然不能说明和DirectByteBuffer没有关系,因为DirectByteBuffer的实例如果没有晋升到Old区,实例的Reference也不为0,这个时候GC也是不能去掉的。DirectByteBuffer问题似乎没有进展,只能换个角度去查询Java Heap之外内存使用很多的问题。这个时候我使用了jstack命令, 看下JVM里面的线程到底在干些什么事情。 jstack -l pid。执行的结果发现一个可疑点,New I/O client worker这种线程的多得惊人:

$grep 'New I/O client worker' jstack_13_30.log | wc -l 
4695

并且多次执行jstack命令发现New I/O client worker的线程一直在增长。并且线程名里面的是序列号也是连续的,所以New I/O client worker这些线程实际上一直在创建,没有销毁过。

"New I/O client worker #4695-1" prio=10 tid=0x00007f080e1ec800 nid=0x32047 runnable [0x00007f085e0ed000]
"New I/O client worker #4694-1" prio=10 tid=0x00007f080e1ec000 nid=0x32044 runnable [0x00007f085e1ee000]
"New I/O client worker #4693-1" prio=10 tid=0x00007f080e1eb800 nid=0x32033 runnable [0x00007f085e5f2000]
"New I/O client worker #4692-1" prio=10 tid=0x00007f0925713800 nid=0x3202f runnable [0x00007f085e7f4000]
"New I/O client worker #4691-1" prio=10 tid=0x00007f0925712800 nid=0x32029 runnable [0x00007f07fd4ff000]
...
...
...
"New I/O client worker #7-1" prio=10 tid=0x00007f09b5250000 nid=0x233de runnable [0x0000000052e42000]
"New I/O client worker #6-1" prio=10 tid=0x00007f09b756f800 nid=0x233d5 runnable [0x000000005263a000]
"New I/O client worker #5-1" prio=10 tid=0x00007f09b4a16800 nid=0x233c7 runnable [0x000000005182c000]
"New I/O client worker #4-1" prio=10 tid=0x00007f09c45d5000 nid=0x23396 runnable [0x000000004e6fb000]
"New I/O client worker #3-1" prio=10 tid=0x00007f09b7c4c800 nid=0x2334c runnable [0x000000004d0e5000]
"New I/O client worker #2-1" prio=10 tid=0x00007f09bc707000 nid=0x2333b runnable [0x000000004c2d7000]
"New I/O client worker #1-1" prio=10 tid=0x00007f09b7bcb800 nid=0x23271 runnable [0x0000000049bb0000]

查看线程的执行栈,发现New I/O client worker执行的是Netty的代码。

"New I/O client worker #4695-1" prio=10 tid=0x00007f080e1ec800 nid=0x32047 runnable [0x00007f085e0ed000]
java.lang.Thread.State: RUNNABLE
    at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
    at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:210)
    at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:65)
    at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:69)
    - locked <0x00000006eba71db0> (a sun.nio.ch.Util$2)
    - locked <0x00000006eba71da0> (a java.util.Collections$UnmodifiableSet)
    - locked <0x00000006eba71b98> (a sun.nio.ch.EPollSelectorImpl)
    at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:80)
    at org.jboss.netty.channel.socket.nio.SelectorUtil.select(SelectorUtil.java:38)
    at org.jboss.netty.channel.socket.nio.NioWorker.run(NioWorker.java:164)
    at org.jboss.netty.util.ThreadRenamingRunnable.run(ThreadRenamingRunnable.java:108)
    at org.jboss.netty.util.internal.IoWorkerRunnable.run(IoWorkerRunnable.java:46)
    at java.util.concurrent.ThreadPoolExecutor$Worker.runTask(ThreadPoolExecutor.java:886)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:908)
    at java.lang.Thread.run(Thread.java:662)

锁定问题在Netty之后,我开始google关于Netty的问题,找到一篇毕玄的文章,里面是这么描述的:对netty熟一些的同学会知道netty创建一个连接后,会将连接交给后端的NioWorker线程,由NioWorker线程来处理具体的I/O读写事件,NioWorker的线程池的线程数默认为cpu * 2,在上面的场景中创建了1w+的NioWorker线程,那只能是更前端让NioWorker产生了更多的线程数。

往上追朔可以看到NioWorker的线程数要创建更多的话,必然是NioClientSocketChannelFactory每次重新创建了,如每次创建连接时,NioClientSocketChannelFactory重新创建,就会直接导致每次创建的这个连接都会扔到一个新的NioWorker线程去处理。

由此可见,只要找到Java代码里面调用NioClientSocketChannelFactory的地方,就能知道对于Netty的使用是否正确。我从应用代码里面搜索NioClientSocketChannelFactory关键字, 但是没有结果。由于我自己使用Java语言较少,于是请教了我们组从事过Java研发的狄卢同学,他写了一个btrace的脚本去抓取什么地方创建的线程,执行的结果能看到线程一直在创建,但是线程的名字并非是New I/O client worker开头。最后请毕玄帮忙排查,毕玄之前排查过类似问题,一眼就知道问题所在,修改下btrace脚本, 改抓取什么地方创建NioClientSocketChannelFactory。

import com.sun.btrace.annotations.*;
import com.sun.btrace.annotations.OnMethod;
import com.sun.btrace.annotations.Self;

import static com.sun.btrace.BTraceUtils.*;

/**
 * Created with IntelliJ IDEA.
 * User: dilu.kxq
 * Date: 14-8-20
 * To change this template use File | Settings | File Templates.
 */
@BTrace
public class NewThreadStack {
    @OnMethod(clazz = "org.jboss.netty.channel.socket.nio.NioClientSocketChannelFactory", method = "<init>")
    public static void func(@Self Object obj) {
        print("----------");
        jstack();
    }
}

执行打印出的栈如下:

----------org.jboss.netty.channel.socket.nio.NioClientSocketChannelFactory.<init>(NioClientSocketChannelFactory.java:120)
org.jboss.netty.channel.socket.nio.NioClientSocketChannelFactory.<init>(NioClientSocketChannelFactory.java:105)
com.XXX.XXX.link.channel.websocket.WebSocketClient.prepareBootstrap(WebSocketClient.java:103)
com.XXX.XXX.link.channel.websocket.WebSocketClient.prepareAndConnect(WebSocketClient.java:83)
com.XXX.XXX.link.channel.websocket.WebSocketClient.connect(WebSocketClient.java:48)
com.XXX.XXX.link.channel.ClientChannelSharedSelector.connect(ClientChannelSharedSelector.java:57)
com.XXX.XXX.link.channel.ClientChannelSharedSelector.getChannel(ClientChannelSharedSelector.java:43)
com.XXX.XXX.link.remoting.DynamicProxy.getChannel(DynamicProxy.java:112)
com.XXX.XXX.link.remoting.DynamicProxy.invoke(DynamicProxy.java:98)
com.XXX.XXX.link.remoting.DynamicProxy.invoke(DynamicProxy.java:80)
com.XXX.XXX.link.remoting.DynamicProxy.invoke(DynamicProxy.java:62)
$Proxy164.pollTask(Unknown Source)
com.XXX.XXX.workflow.framework.TaskWorkerManager.execute(TaskWorkerManager.java:89)
com.XXX.XXX.workflow.framework.worker.AbstractWorkerManager$1.run(AbstractWorkerManager.java:164)
java.util.concurrent.ThreadPoolExecutor$Worker.runTask(ThreadPoolExecutor.java:886)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:908)
java.lang.Thread.run(Thread.java:662)

prepareBootstrap方法属于一个二方jar包,下载二方jar的源码查看实现果然如毕玄的文章所说一个链接创建了一个线程:

private static ClientBootstrap prepareBootstrap(Logger logger,
    WebSocketClientUpstreamHandler wsHandler,
    SslHandler sslHandler,
    int connectTimeoutMillis) {
    ClientBootstrap bootstrap = new ClientBootstrap(new NioClientSocketChannelFactory(
        Executors.newCachedThreadPool(),
        Executors.newCachedThreadPool()));

    bootstrap.setOption("tcpNoDelay", true);
    bootstrap.setOption("reuseAddress", true);
    bootstrap.setOption("connectTimeoutMillis", connectTimeoutMillis);
    bootstrap.setOption("writeBufferHighWaterMark", 10 * 1024 * 1024);

NioClientSocketChannelFactory不应该每次都是创建,链接应该复用NioClientSocketChannelFactory这个类的实例。和二方包的作者确认过问题之后,得知新版本已经修复了这个问题,通知应用方的研发升级版本,虽然最终应用方的研发没有升级代码,而是去掉了对于这块业务的调用,问题得到解决。

总结:

虽然应用方去掉了对于Netty的调用使得问题得以修复,但是,如果你细心去计算堆外内存的使用,你会发现其实线程的创建可能并不会导致Java堆外内存的泄漏很严重,因为JVM中Heap空间是所有Java线程共用的,Java线程最直接的开销就是Java线程的执行栈,HotSpot虚拟机默认的Java线程栈的大小是1MB的虚拟地址空间,实际上Java线程一般使用不到1MB的栈空间,运行了的Java线程最少使用一个页(4k)的栈空间,如果JVM crash的时候有6000个线程,按照最少的栈使用大概需要24MB内存就足够了。我不知道一般线程运行时使用的栈空间大小,所以没办法计算准确的数值。

Netty高性I/O能框架,核心功能就是使用了NIO, NIO使用的是epoll系统调用和DirectByteBuffer,所以又回到了DirectByteBuffer,最早的分析我们没有发现DirectByteBuffer有问题,但是也没有排除,事后,我在想有没有办法知道DirectByteBuffer Java堆外内存的使用,果然有大拿做了个Java程序能分析出Java堆外DirectByteBuffer的内存使用,DirectMemorySize.java, 这个程序直接分析Java进程,也能分析core文件,我在排查过程中使用gdb生成了一个core文件,所以我直接分析core文件,输出如下:

$java -cp .:$JAVA_HOME/lib/sa-jdi.jar DirectMemorySize $JAVA_HOME/bin/java core.143748
Attaching to core core.143748 from executable /opt/java//bin/java, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 20.0-b12-internal
NIO direct memory: (in bytes)
  reserved size = 2276.261724 MB (2386833414 bytes)
  max size      = 3891.250000 MB (4080271360 bytes)

可以看到DirectByteBuffer最大Java对外内存使用到达3G+, 目前在使用的也有2G+, 2GJava堆外内存加上4GJava堆内存,跟top命令输出java进程占用6.2G的物理内存基本一致。 如果Netty线程创建很多不是直接导致堆外内存使用很多的原因,那么即使使用新版本的二方包能否解决Java堆外内存很多的问题呢,这要看Netty的实现,我没有去细看代码,应用方也没有升级到新版本的二方包去测试。有文档介绍Netty是使用内存池管理内存,如果一个NioClientSocketChannelFactory实例创建一个内存池,那么减少NioClientSocketChannelFactory实例,也就是减少Netty线程的创建是有效果的,如果DirectByteBuffer用于每个单独的链接,每个链接都有自己的内存,那么可能减少线程的创建不能解决Java内存堆外内存使用过多的问题。

有兴趣的读者可以再去了解下NIO的实现方式epoll的模型,其实了解epoll系统调用的同学可能对于NIO的实现方式秒懂,为什么不是一个链接一个线程,而是可以很多链接一个线程处理。故障排查归根结底,还是毕玄那句:知其因,惟手熟尔。原理性的东西理解得越多就越不需要上层代码是如何编写,底层的东西共性的实在太多,从下往上查询问题,不需要知道应用的业务代码如何实现,最重要的还是多去实践。

参考链接:

1.http://hellojava.info/?p=184
2.http://www.ibm.com/developerworks/cn/java/j-lo-javaio/
3.http://www.infoq.com/cn/articles/netty-high-performance
4.https://gist.github.com/rednaxelafx/1593521
5.http://hellojava.info/?p=56

Linux Kernel Load Average计算分析

这篇文章是我对于Linux Kernel Load Average计算的个人理解,因为到目前为止,我还是没有完全搞明白。我搜索了网上很多文章,依然没有搞明白,主要原因有三个,一是我的数学知识基础很差,很多文章中提到的数学公式转换我看不明白(有些甚至是错误的);另外一个是看英文资料比较费劲(尽管我一直努力装作能看懂^_^);第三,很多介绍Linux Kernel Load Average计算的文章重点介绍的是当前活跃进程数是如何得到的,并没有介绍load在一段时间内的平均值是怎么计算。尽管如此,经过一段时间的学习和探讨,对于计算Load Average过程已经理解部分我觉得还是有必要记录下来。

load是系统负载很重要的一个指标,top, uptime, w三个命令都能查看系统在前1min, 5min, 15min中的load平均值(Load Average), 但是Linux Kernel对于load一段时间内的平均值计算和打印却很复杂。主要原因我认为有两个:

1.load的计算实际上使用的是数学概率和统计中时间序列预测法中的指数平滑法;
2.Linux Kernel不能直接做浮点运算(Floating-point arithmetic),只能做定点运算(Fixed-point arithmetic),如果不了解定点运算,Linux Kernel Load Average的代码更难理解。

所以核心的两点是要先了解什么是指数平滑法和定点运算。

1.指数平滑法(Exponential smoothing)

指数平滑法是布朗(Robert G..Brown)所提出,指数平滑法常用于生产预测,比如中短期经济发展预测。最简单的预测方法全期平均法把历史一段时间的值求平均数,使用这个平均数去预测下一个时间段的发展趋势,这种预测方法需要对历史数据一个不漏地全部加以同等利用,并且这种预测方法适用于预测对象变化较小且无明显趋势。另外一种称作移动平均法,这种预测方法不考虑远期数据(移动平均法具体的细节没有了解过:) )。指数平滑法兼容了全期平均法和移动平均法所长,不舍弃过去的数据,但是仅给予逐渐减弱的影响程度。 指数平滑的基本公式如下: exponential_smoothing_formula [0<α<1]

St :时间t的平滑均值。
Xt-1 :时间t-1的实际值。
St-1 :时间t-1的平滑均值。
α :平滑常数(平滑因子)。

从上面的公式可以看出,要预测t时刻的平滑均值St只要得到t-1时刻的平滑均值St-1和t-1时刻的当前值Xt-1,α是一个平滑常数(有时称作平滑因子),α是一个常量[0<α<1],α的选取对于指数平滑公式的准确度很重要,当α越趋近于1,St-1对于St的影响就越小,Xt-1对于St的影响就越大,反之亦然。α的选取往往是从历史数据中提取出来St-1可以扩展成St-1 = α * Xt-2 + (1 – α) * St-2,并且St-n可以继续扩展下去,直到n=0,由此可以得出历史预测值St-n n越小对于当前St的影响就越小,这是一个衰减的过程。

指数平滑又分为一次指数平滑,二次指数平滑和三次指数平滑。一次指数平滑和指数平滑的基本公式没有区别,我们也只考虑这种情况。这种预测方法的好处是它既不需要存储全部历史数据,也不需要存储一组数据。

有时通过一段时间的收集发现平滑指数的预测偏离了实际的数值,需要通过趋势调整,添加一个趋势修正值,可以一定程度上改进指数平滑预测结果,调整后的指数平滑公式为:

St = α * Xt-1 + (1 – α) * St-1 + Tt, [0 < α < 1]

Tt也是通过一段时间的历史数据计算得来的一个值,具体我们就不深究了。

Linux Kernel对于load 均值的计算是在时钟中断里面完成,所以要求尽快完成,能存储的历史数据自然是有限。历史数据越多,运算花费的时间就越多,简而言之,就是处理越快越好!指数平滑法能很好应用到load均值计算中,它要求存储的历史数据很少,并且平滑因子选取正确就能正确计算出load的均值。但是Linux Kernel对于load均值的计算不是预测未来,而是计算这一时刻前1min, 5min, 15min的平滑均值。以1min为例,指数平滑公式是预测未来1min的平滑均值,而Linux Kernel要通过当前时刻值和1min之前的平滑均值来计算最近1min的平滑均值。Linux Kernel给出了自己的计算公式,这种数学上的变换对于我这种数学基础很差的人来说是理解不了的。(^_^!! 如果你知道是如何变换的,请邮件给我告知,谢谢!),Linux Kernel的计算公式是:

loadt = loadt-1 * α + n * (1 – α),[0 < α < 1]

这是linux-2.6.18里的load均值计算公式,在最近版本(3.12)的linux kernel中,load均值的计算公式中增加了一个很小的趋势修正值z(没弄明白为啥。)。公式如下:

loadt = loadt-1 * α + n * (1 – α) + z,[0 < α < 1]

n表示当前进程数(实际上是RUNNABLE状态和TASK_UNINTERRUPTIBLE状态的进程数)。
loadt表示当前时刻一段时间内的平滑均值。
loadt-1表示上一时间段的平滑均值。
α的选取又是一个以我的数学基础不能理解的值,貌似跟电容里面的充电和放电过程类似,(学通信和信号处理的同学应该清楚些)。 Linux Kernel要计算的是前1min, 5min, 15min的Load 均值,α需要分别选取。Linux Kernel选取的是: e-5/(60*m)
5:表示5s,作分子。
60:表示60s。
m: 表示分钟,1, 5, 15。 60 * m作为分母。
把m带入到公式计算,分别能计算出0.9200444150.9834714540.994459848,这三个值我们先记下,后面还会用到。

是不是到目前为止就能完全理解Linux Kernel对于Load均值的计算过程呢,NO!。Linux Kernel 不能做浮点运算,不能直接在内核里面定义float或double类型的变量,而load是一个需要有小数的值,并且[0 < α < 1]也是小数,所以Linux Kernel不能直接运用公式。

2.定点运算(Fixed-point arithmetic)

定点运算是相对于浮点计算(Floating-point arithmetic)来说的。浮点数和定点数只是针对小数点而言,小数点是浮动的就是浮点数,小数点是固定的,就是定点数。有些架构本身就不支持浮点运算单元(FPU),比如有些DSP芯片。当遇到在不支持或者不能使用浮点运算的环境时,浮点运算转换成定点运算,因为定点运算使用的是整数。使用定点数首先需要指定小数点的地方,比如指定一个数的低3位表示小数。举个例子:1500是一个定点数,这个定点数的低3位表示小数,也就是定点数1500相当于浮点数的1.500。在10进制中,浮点数转换成定点数,只要把浮点数*10n(n表示定点数的小数位数)。比如定点数的小数部分的位数是3位,那么浮点数1.500的定点表示就是1.500 * 103 = 1500; 如果是浮点数精度位数大于定点数中小数的位数,精度将被丢弃,比如1.5005, 1.5005 * 103 = 1500的定点数。也就是定点数中小数的位数就是小数的精度。对于二进制数而言其实也是一样的,比如一个32位的定点数,低11位表示存放小数,那边低11位就是二进制数的小数精度。

现在继续考虑定点数的运算加减乘除。还是以10进制数为例,浮点数0.5转换成3位精度的定点数为0.5 * 103 = 500。 当两个浮点数相加时0.5 + 0.5 = 1.0 转换成定点计算应该是500 + 500 = 1000, 结果1000还是一个定点数,定点数1000转换回浮点数的时候1000 / 103 = 1。加法的运算没有问题,同样减法也是没有问题的。如果是乘法会是怎样呢?浮点数0.5 * 0.5 = 0.25,转换成定点数 500 * 500 = 250000,结果250000却不是我们想要的值, 因为250000转换成浮点数时250000 / 103 = 250, 所以定点乘法运算要进行一定的修正,修正的方法是在乘法的结果上除以103,所以定点数的乘法运算方式是 (500 * 500) / 103。 若是除法,则刚好和乘法相反, 浮点数0.5 / 0.02 = 25 转换成定点运算500 / 20 = 25,定点数25再转换成浮点数便是0.025, 而实际浮点计算的结果是25,所以除法运算的方式是 (500 / 20 ) * 103 = 25000,这样在转换回浮点数的时候就是正确的。

虽然是以10进制为例,但是在2进程中的运算也是一样的。 定点运算总结出来的结果就是:
1.定点数的加法和减法是可以直接进行的
2.定点数的乘法需要在乘法运算的结果之后除以bn(b:进制, n表示小数的位数)进行修正
3.定点数的除法需要在除法运算的结果之后乘以bn(b:进制, n表示小数的位数)进行修正

3.Linux Kernel Load Average计算公式推导

经过前面对于指数平滑法和定点运算的分析,我们再来推导Linux Kernel Load Average的计算方式。 首先Linux Kernel对于load 1min,5min,15min之前的load均值计算公式如下:
loadt = loadt-1 * α + n * (1 – α),[0 < α < 1]
平滑常量α对应于1min,5min,15min分别是0.920044415,0.983471454,0.994459848。

前面说了,这个公式不能直接在Linux Kernel里面用浮点数的方式计算出来,那么只能把上面的公式通过定点数来计算。以1min的计算过程为例,小数位数为2进制的11位。
1.首先需要把平滑常量α 0.920044415转换成定点数:0.920044415 * 211 = 1884.
2.当前进程数n和常数1也要转换成定点数: n * 211, 1 * 211
3.浮点运算 n * (1 – α) 就转换成了 ((n * 211) * ((1 * 211) – 1884)) / 211
4.loadt-1 * α 转换稍微有点特殊,当t=1时,loadt-1 = load0,也就是load的最初始值,如果load的最初始值为0,那么定点数和浮点数表示都是一样的,如果load最初始值大于0,首先需要把load最初始值转换成定点数。所以loadt-1本身就是定点数不需要转换。最终转换成 (loadt-1 * 1884) / 211
5.整个公式就转换成了:
loadt * 211 = (loadt-1 * 1884 + (n * 211) * ((1 * 211) – 1884)) / 211

4.Linux Kernel Load Average的计算和打印代码分析

现在,是时候去看看Linux Kernel代码,Kernel实际是怎么做的。首先内核定义了一些宏。

include/linux/sched.h

158 #define FSHIFT      11      /* nr of bits of precision */
159 #define FIXED_1     (1<<FSHIFT) /* 1.0 as fixed-point */
160 #define LOAD_FREQ   (5*HZ+1)    /* 5 sec intervals */
161 #define EXP_1       1884        /* 1/exp(5sec/1min) as fixed-point */
162 #define EXP_5       2014        /* 1/exp(5sec/5min) */
163 #define EXP_15      2037        /* 1/exp(5sec/15min) */

FSHIFT定义的是定点运算中11位表示小数的精度; FIXED_1就是定点数的1.0; EXP_1, EXP_5, EXP_15分别表示平滑常数的α的定点数表示。根据指数平滑公式,平滑常数α确定之后,只要知道历史的平滑均值和当前的实际值,就能计算出当前的平滑均值。Linux Kernel每5s计算一次, LOAD_FREQ定义的就是5s。接着看代码:

kernel/sched/proc.c
65 /* Variables and functions for calc_load */
66 atomic_long_t calc_load_tasks;
67 unsigned long calc_load_update;
68 unsigned long avenrun[3];
69 EXPORT_SYMBOL(avenrun); /* should be removed */
......
101 /*
102  * a1 = a0 * e + a * (1 - e)
103  */
104 static unsigned long
105 calc_load(unsigned long load, unsigned long exp, unsigned long active)
106 {
107     load *= exp;
108     load += active * (FIXED_1 - exp);
109     load += 1UL << (FSHIFT - 1);
110     return load >> FSHIFT;
111 }
......
346 /*
347  * calc_load - update the avenrun load estimates 10 ticks after the
348  * CPUs have updated calc_load_tasks.
349  */
350 void calc_global_load(unsigned long ticks)
351 {
352     long active, delta;
353
354     if (time_before(jiffies, calc_load_update + 10))
355         return;
356
357     /*
358    * Fold the 'old' idle-delta to include all NO_HZ cpus.
359     */
360     delta = calc_load_fold_idle();
361     if (delta)
362         atomic_long_add(delta, &calc_load_tasks);
363
364     active = atomic_long_read(&calc_load_tasks);
365     active = active > 0 ? active * FIXED_1 : 0;
366
367     avenrun[0] = calc_load(avenrun[0], EXP_1, active);
368     avenrun[1] = calc_load(avenrun[1], EXP_5, active);
369     avenrun[2] = calc_load(avenrun[2], EXP_15, active);
370
371     calc_load_update += LOAD_FREQ;
372
373     /*
374     * In case we idled for multiple LOAD_FREQ intervals, catch up in bulk.
375     */
376     calc_global_nohz();
377 }

首先看到68行的avenrun定义,这是一个类型为unsigned long大小为3的数组,分别用于存放1min, 5min, 15min的load均值,由于avenrun定义的全局变量,内核编译时会初始化为0,所以avenrun[0], avenrun[1], avenrun[2]的运行时初始值都为0。calc_global_load()对avenrun的值进行计算,354行表示如果LOAD_FREQ(5s)没有消耗掉,就直接退出,也就是统计的周期是5s,(354行代码里面加10的原因函数开头的注释已经说明了),load均值计算完成之后,371行对calc_load_update更新,加上LOAD_FREQ。calc_load_tasks存放的是RUNNABLE和TASK_UNINTERRUPTIBLE进程的数量,这个值在calc_global_load()之外更新,364行读取calc_load_tasks到active,365行把active转换成定点数表示。367,368和369行就是分别对1min,5min,15min的load均值计算,计算的过程都是调用calc_load()。

calc_load()就是我们上面分析的指数平滑公式的定点运算方法。此时已经基本清楚了Linux Kernel对于load均值的计算方式,下面再看下Linux Kernel如何从定点数中把load的均值打印成浮点形式,不仅如此,我们知道top命令的输出,小数点是之后是有两位的,也就是小数点之后2位还需要做4舍5入。具体代码如下:

fs/proc/loadavg.c
10 #define LOAD_INT(x) ((x) >> FSHIFT)
11 #define LOAD_FRAC(x) LOAD_INT(((x) & (FIXED_1-1)) * 100)
12
13 static int loadavg_proc_show(struct seq_file *m, void *v)
14 {
15     unsigned long avnrun[3];
16
17     get_avenrun(avnrun, FIXED_1/200, 0);
18
19     seq_printf(m, "%lu.%02lu %lu.%02lu %lu.%02lu %ld/%d %d\n",
20         LOAD_INT(avnrun[0]), LOAD_FRAC(avnrun[0]),
21         LOAD_INT(avnrun[1]), LOAD_FRAC(avnrun[1]),
22         LOAD_INT(avnrun[2]), LOAD_FRAC(avnrun[2]),
23         nr_running(), nr_threads,
24         task_active_pid_ns(current)->last_pid);
25     return 0;
26 }

宏LOAD_INT(x)用作取定点数x整数部分,宏LOAD_FRAC(x)用于取定点数x小数部分的10进制的两位,(x) & (FIXED_1-1)就是取到定点数x的小数部分, (x) & (FIXED_1-1) * 100使得小数部分10进制的两位溢出到整数部分,再调用LOAD_INT就能把溢出到整数的10进制2位取出来。4舍5入又是怎么实现的呢? FIXED_1/200实际上是小数0.005的定点表示,假如load均值小数部分是0.00x,x>=5 0.00x + 0.005就会往高位进1,否则没有影响。最后看下get_avenrun的实现:

kernel/sched/proc.c
79 void get_avenrun(unsigned long *loads, unsigned long offset, int shift)
80 {
81     loads[0] = (avenrun[0] + offset) << shift;
82     loads[1] = (avenrun[1] + offset) << shift;
83     loads[2] = (avenrun[2] + offset) << shift;
84 }

5.参考资料(References)

http://wiki.mbalib.com/wiki/%E6%8C%87%E6%95%B0%E5%B9%B3%E6%BB%91%E6%B3%95 (指数平滑)
http://en.wikipedia.org/wiki/Exponential_smoothing
http://bbs.ednchina.com/BLOG_ARTICLE_1899924.HTM (定点运算)
http://en.wikipedia.org/wiki/Fixed-point_arithmetic
http://en.wikipedia.org/wiki/Load_(computing)
http://www.teamquest.com/pdfs/whitepaper/ldavg1.pdf
http://www.teamquest.com/pdfs/whitepaper/ldavg2.pdf
http://www.teamquest.com/pdfs/whitepaper/ldavg3.pdf
http://www.eecs.berkeley.edu/Pubs/TechRpts/1987/CSD-87-353.pdf
http://luv.asn.au/overheads/NJG_LUV_2002/luvSlides.html
http://ilinuxkernel.com/?p=869

进程间共享inode相互影响简单分析

起因:

上周我们团队的实习生做了一个rsyslog+logrotate配置的分享,logrotate有一个配置指令create, 意思是在轮转日志的时候先把之前的文件删除,然后重新创建一个新的文件。文件名可以是不同名,也可以是同名,由此我想到一个问题,如果是同名文件,使用create指令的方式能有什么益处?

这篇文章假设的一个前提是文件是同名的,为了做日志轮转,有一种方式就是直接清空现有的文件,然后新的日志还是往这个文件里面写入。但是使用create指令的方式或许能在多进程读写相同文件时避免一些问题,请看下文。

准备工作:

为了模拟多个进程同时读写相同的文件,我写了几个小程序(C语言,目前只会点C,读者见谅),第一个程序是一个写程序(write.c):

  1 #include <stdio.h>
  2 #include <sys/types.h>
  3 #include <sys/stat.h>
  4 #include <fcntl.h>
  5 #include <unistd.h>
  6 
  7 int main(void) {
  8     int fd;
  9     int i;
 10     ssize_t w_size;
 11 
 12     fd = open("/home/bryton/a",  O_RDWR | O_APPEND);
 13     if ( fd != -1 ) {
 14         for (i = 0; i < 10000; i++) {
 15             w_size = write(fd, "adcdef\n", sizeof("adcdef\n") -1);
 16             if ( w_size == -1 ) {
 17                 printf("write error\n");
 18                 return -1;
 19             }
 20             sleep(1);
 21         }
 22         close(fd);
 23 
 24     } else {
 25         return -1;
 26     }
 27     return 0;
 28 }

这个程序很简单,以追加的方式往/home/bryton/a这个文件里面追加内容,for循环10000次往文件a追加”adcdef\n”字符串,每追加一次,sleep 1s,这样不至于10000次循环很快就跑完了。

第二个程序是一个读程序(read.c):

  1 #include <stdio.h>
  2 #include <string.h>
  3 #include <sys/types.h>
  4 #include <sys/stat.h>
  5 #include <fcntl.h>
  6 #include <unistd.h>
  7 
  8 
  9 int main(void) {
 10     int fd;
 11     char buffer[1024];
 12     ssize_t read_size;
 13 
 14     fd = open("/home/bryton/a", O_RDONLY);
 15     if ( fd != -1 ) {
 16         while ( 1 ) {
 17             memset(buffer, 0, 1024);
 18             read_size = read(fd, buffer, 1024);
 19             if ( read_size > 0 ) {
 20                 printf("%s", buffer);
 21             }
 22         }
 23     } else {
 24         return -1;
 25     }
 26     close(fd);
 27     return 0;
 28 }

读程序也是很简单, while( 1 ) { … } 一直读/home/bryton/a这个文件,每读一次打印一次。

实验

有了这两个程序我们就能做一个小实验。首先通过gcc编译这两个小程序:

$ gcc -o write write.c
$ gcc -o read read.c

接着在/home/bryton/目录下创建一个名为a文件(笔者自己的HOME目录是/home/bryton,在读者自己的机器测试时可以改成对应的目录,同时程序也要小小的修改下)

$ touch a
$ tail -f a

然后在开启另外一个终端,在另外一个终端里面输入:

$ ./write

这时可以看到tail -f a 有输出:

$ tail -f a
adcdef
adcdef
adcdef
adcdef
adcdef

确定write有效之后,按Control – c键退出tail命令,接着执行:

$ ./read
adcdef
adcdef
adcdef
adcdef

我们能看到和tail -f一样的输出,说明read程序也正常工作了。接下来要做的这一步很重要,如果把/home/bryton/a文件删除会如何?write和read这两个小程序还能正常工作吗?读者朋友可以先想一想不要着急回答。

$ rm -f a 

删除之后,只能观察./read是否依然能正常输出,因为./write本来就没有输出。如果还是依然能正常输出,就说明read和write两个小程序依然工作正常,结果是这两个程序依然能正常工作,如下:

$ ./read
adcdef
adcdef
adcdef
adcdef
adcdef
......
......
adcdef
adcdef

我用……表示中间已经输出了很多行,由于不能动画显示,也只能这样了,读者可以自己做实验。

不知道这样的结果读者是否已经提前预知,不管怎么样,到此为止要想想为什么了,rm已经把文件删除,在终端下通过ls命令也无法看到,但是read和write还能正常工作,什么原因使得这两个程序还能正常工作呢。其实文件一般在文件系统中可以简单的认为由两部分组成,一部分称为inode,inode用于描述文件的基本信息包括文件名,inode号,访问时间,修改时间,权限等信息,inode的数量是在文件系统格式化的时候创建的,另外一部分称为data block, 显然data block用于存放文件的实际数据;而目录对于文件系统其实也是一个文件,与文件不同的是,目录的data block存放的是目录下文件的信息,其中很重要的就是文件的inode号,inode是标识文件的唯一关键信息,通常认为文件名和路径标识一个文件的认识是不全面的。

有了inode是标识文件唯一的认识之后,继续分析上面的实验,./write和./read通过open系统调用打开的文件/home/bryton/a实际上是把/home/bryton/a的inode从文件系统读入到内存,open系统调用把inode读入到内存之后,通过一个文件描述符(file descriptor)关联到inode,通过文件描述符读取(read)或者写入(write)到文件最终转换成通过inode对文件进行操作,虽然通过rm命令在文件系统上删除了/home/bryton/a这个文件,但是实际上这个文件只是从/home/bryton目录的data block里面去除了a的inode信息,并没有从文件系统上删除掉

所以./write还能继续往文件系统里面写, ./read还能继续从文件系统里面读取出来,通过一张图加深下对此刻的理解:

file_deleted_in_dir_inode_shared_by_processers

Tips: 1. *nix系统有一个lsof命令能看到目前进程正在打开的文件有哪些。 通过lsof | grep deleted 能查询到在目录里面删除了,但是依然被进程打开了的文件。

Tips: 2. 如果一个文件被进程打开着,并且由于人为误操作把文件删除了(运维当中偶尔会遇到),可以通过cp /proc/PID/fd/FD /path/to/save/
进行恢复,PID表示打开了这个文件的进程ID,FD表示进程打开的文件描述符号。

回到文章开始的事情上,如果logrotate配置create指令轮转同名文件日志,虽然日志文件在目录下面找不到了,但是对于已经在处理日志的进程来说却不受影响,新创建的文件用于存放新的日志,老的日志进程依然能正确处理。

如果logrotate不是配置create指令,对于正在处理日志文件的进程到底会有什么样的影响呢? 我们还可以做一个实验,这个实验验证两种情况:

1.文件被进程以读的方式打开,文件被清空对于正在读取文件的进程有什么影响。
2.文件被进程以写的方式打开,文件被清空对于正在写的进程有什么影响。

实验2

为了验证第一种进程以读方式打开文件,文件被清空的情况,需要一个新的read程序—read2.c, read2.c只读取两次,在读取完第一次并打印之后,进程睡眠10s,这样有10s钟的操作时间去清空文件,清空文件的操作没有重新去写一个新的清空程序,而是使用bash的’>‘操作。

  1 #include <stdio.h>
  2 #include <sys/types.h>
  3 #include <sys/stat.h>
  4 #include <fcntl.h>
  5 #include <unistd.h>
  6 #include <string.h>
  7 
  8 int main(void) {
  9     int fd;
 10     char buffer[128];
 11     ssize_t read_size;
 12 
 13     fd = open("/home/bryton/b", O_RDONLY);
 14     if ( fd != -1 ) {
 15         memset(buffer, 0, 128);
 16         read_size = read(fd, buffer, 127);
 17         if ( read_size > 0 ) {
 18             printf("%s", buffer);
 19         }
 20         printf("\nsleep 10s...\n");
 21         sleep(10);
 22         memset(buffer, 0, 128);
 23         read_size = read(fd, buffer, 127);
 24         if ( read_size > 0) {
 25             printf("%s", buffer);
 26         }
 27     } else {
 28         return -1;
 29     }
 30     return 0;
 31 }

验证过程如下: 首先在HOME目录下创建一个b文件,内容如下:

  1 aaaaaaaaaaaaaaaaaa
  2 aaaaaaaaaaaaaaaaaa
  3 aaaaaaaaaaaaaaaaaa
  4 aaaaaaaaaaaaaaaaaa
  5 aaaaaaaaaaaaaaaaaa
  6 aaaaaaaaaaaaaaaaaa
  7 aaaaaaaaaaaaaaaaaa
  8 bbbbbbbbbbbbbbbbbb
  9 bbbbbbbbbbbbbbbbbb
 10 bbbbbbbbbbbbbbbbbb
 11 bbbbbbbbbbbbbbbbbb
 12 bbbbbbbbbbbbbbbbbb
 13 bbbbbbbbbbbbbbbbbb
 14 bbbbbbbbbbbbbbbbbb

编译并执行read2.c

$ gcc -o read2 read2.c
$ ./read2
aaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaa
sleep 10s...

当read2打印出sleep 10s的时候立即在另外一个终端执行清空文件b的操作:

$ >b

10s之后,read2读第二次,但是没有任何输出,由此可见当清空一个正在被进程读的文件,进程将不能读取到文件后面的内容,如果是一个日志处理程序,在logrotate清空文件这一刻,如果文件内容没有处理完,内容将会丢失。

接着验证第二种情况。当一个进程以写的方式打开一个文件时,文件被清空会有什么影响。写日志文件复用write.c程序,清空操作还是使用bash的’>‘。首先编译并执行write.c

$ gcc -o write write.c
$ ./write
$

接着在另外一个终端先cat a这个文件

$ cat a
adcdef
adcdef
adcdef
adcdef
adcdef
adcdef
adcdef
adcdef
adcdef
$ 

然后执行清空操作并立即cat a查看文件的变化

$ >a
$ cat a
adcdef
adcdef
$

此时能够看到文件的内容看起来像是被截断了一样,其实文件已经被清空,新增的两行是write程序在清空之后新写入的(write程序始终写入的是adcdef字符串,所以不太好区分旧的输出和新的写入)。

利用这个特性,在运维高可用的应用时有一个非常好的用途,高可用的程序往往不能重启,程序以追加的方式写日志文件。当磁盘空间不足时,必须要清空磁盘空间才能使程序继续运行。如果直接删除日志文件而不重启进程会发现磁盘空间依然没有得到释放,du命令查看目录下的文件占用空间是变少了,但是df查看整个磁盘的空间确依然没不减。如果日志并不重要,这个时候可以使用清空的方式,不需要重启进程,也能释放磁盘空间。

C语言宏对于调试的影响

最近在写一个nginx的模块,过程中遇到一个小问题,感受到了C语言宏跟函数的区别,记录下来。

问题现象:

先看一段相关代码

393    ngx_http_stylecombine_body_filter(ngx_http_request_t *r, ngx_chain_t *in)   
394    {                                                                   
395     int                   rc;                                       
396     ngx_chain_t                    out;
397     ngx_http_stylecombine_ctx_t  *ctx;                                      
398                                                                        

399     ctx = ngx_http_get_module_ctx(r, ngx_http_stylecombine_filter_module);  
400                                                                     
401     if (ctx == NULL || r->header_only) {               
402         return ngx_http_next_body_filter(r, in);                    
403     } 

399行通过ngx_http_get_module_ctx获取ctx,401行if语句判断ctx是否为空。在调试的过程中发现402行的ngx_http_next_body_filter总是会执行到,ngx_http_stylecombine_body_filter函数提前返回。

很显然。要么ctx== NULL,要么就是r->header_only不为0,马上启动gdb,下断点在ngx_http_stylecombine_body_filter,运行之。程序停止在393行,单步执行到401行,通过print命令打印出变量ctx和r->header_only, 如下所示:

(gdb) p ctx
$29 = (ngx_http_stylecombine_ctx_t *) 0x989680
(gdb) p r->header_only
$30 = 0

当天调试得比较晚,比较粗心没发现其中的问题。 乍看之下401行的if语句不应该为真, ctx有值,r->header_only也为0, 那为什么402行还是会被执行呢?

问题分析:

遇到这种问题,我常用的方法是先使用gdb的反汇编功能,然后指令级单步跟踪。(BTW:虽然我的汇编不好,马马虎虎能读反汇编后的结果)下面是ngx_http_stylecombine_body_filter反汇编片段:

(gdb) disassemble 
Dump of assembler code for function ngx_http_stylecombine_body_filter:
0x0000000000487b8b <ngx_http_stylecombine_body_filter+0>:    push   %r15
0x0000000000487b8d <ngx_http_stylecombine_body_filter+2>:   push   %r14
0x0000000000487b8f <ngx_http_stylecombine_body_filter+4>:   push   %r13
0x0000000000487b91 <ngx_http_stylecombine_body_filter+6>:   push   %r12
0x0000000000487b93 <ngx_http_stylecombine_body_filter+8>:   push   %rbp
0x0000000000487b94 <ngx_http_stylecombine_body_filter+9>:   push   %rbx
0x0000000000487b95 <ngx_http_stylecombine_body_filter+10>:  sub    $0x68,%rsp
0x0000000000487b99 <ngx_http_stylecombine_body_filter+14>:  mov    %rdi,%r14
0x0000000000487b9c <ngx_http_stylecombine_body_filter+17>:  mov    %rsi,%r13
0x0000000000487b9f <ngx_http_stylecombine_body_filter+20>:  mov    0x10(%rdi),%rdx
0x0000000000487ba3 <ngx_http_stylecombine_body_filter+24>:  mov       0x234c76(%rip),%rax        # 0x6bc820 <ngx_http_stylecombine_filter_module>
0x0000000000487baa <ngx_http_stylecombine_body_filter+31>:  mov    (%rdx,%rax,8),%rax
0x0000000000487bae <ngx_http_stylecombine_body_filter+35>:  test   %rax,%rax
0x0000000000487bb1 <ngx_http_stylecombine_body_filter+38>:  je     0x487bbc <ngx_http_stylecombine_body_filter+49>
0x0000000000487bb3 <ngx_http_stylecombine_body_filter+40>:  testb  $0x2,0x468(%rdi)
0x0000000000487bba <ngx_http_stylecombine_body_filter+47>:  je     0x487bd0 <ngx_http_stylecombine_body_filter+69>
0x0000000000487bbc <ngx_http_stylecombine_body_filter+49>:  mov    %r13,%rsi
0x0000000000487bbf <ngx_http_stylecombine_body_filter+52>:  mov    %r14,%rdi
0x0000000000487bc2 <ngx_http_stylecombine_body_filter+55>:  callq  *0x24f140(%rip)        # 0x6d6d08 <ngx_http_next_body_filter>
0x0000000000487bc8 <ngx_http_stylecombine_body_filter+61>:  mov    %rax,%rdx
0x0000000000487bcb <ngx_http_stylecombine_body_filter+64>:  jmpq   0x488175 <ngx_http_stylecombine_body_filter+1514>
0x0000000000487bd0 <ngx_http_stylecombine_body_filter+69>:  mov    %rax,(%rsp)
0x0000000000487bd4 <ngx_http_stylecombine_body_filter+73>:  mov    0x18(%rax),%rax
0x0000000000487bd8 <ngx_http_stylecombine_body_filter+77>:  cmp    $0x1,%rax
0x0000000000487bdc <ngx_http_stylecombine_body_filter+81>:  je     0x487c49 <ngx_http_stylecombine_body_filter+190>
0x0000000000487bde <ngx_http_stylecombine_body_filter+83>:  cmp    $0x1,%rax
0x0000000000487be2 <ngx_http_stylecombine_body_filter+87>:  jb     0x487bfd <ngx_http_stylecombine_body_filter+114>
0x0000000000487be4 <ngx_http_stylecombine_body_filter+89>:  cmp    $0x2,%rax
0x0000000000487be8 <ngx_http_stylecombine_body_filter+93>:  je     0x487d71 <ngx_http_stylecombine_body_filter+486>
0x0000000000487bee <ngx_http_stylecombine_body_filter+99>:  cmp    $0x3,%rax
0x0000000000487bf2 <ngx_http_stylecombine_body_filter+103>: jne    0x4880c5 <ngx_http_stylecombine_body_filter+1338>
0x0000000000487bf8 <ngx_http_stylecombine_body_filter+109>: jmpq   0x4880b4 <ngx_http_stylecombine_body_filter+1321>
---Type <return> to continue, or q <return> to quit---quit

地址0x0000000000487bbc <ngx_http_stylecombine_body_filter+49>开始到0x0000000000487bc2 <ngx_http_stylecombine_body_filter+55>之间有3条指令,前两条指令是给第3条调用指令传递的参数。x86_64的ABI规定低于6个参数的函数默认使用寄存器传参,寄存器传参顺序是rdi,rsi,rdx,rcx,r8和r9。也就是rdi是ngx_http_next_body_filter的第一个参数r, rsi是ngx_http_next_body_filter的第二个参数in。 r13,r14两个寄存器的内容来源于0x0000000000487b99和0x0000000000487b9c处的两条指令。ngx_http_stylecombine_body_filter和ngx_http_next_body_filter是一样的参数形式,回头去看0x0000000000487b99和0x0000000000487b9c处的两条指令,很容易发现ngx_http_stylecombine_body_filter函数在使用r和in之前各保存到了一份在r14和r13两个寄存器中。

从上面反汇编的结果可以看出,如果要执行到ngx_http_next_body_filter退出,程序的控制流必定是会流经ngx_http_stylecombine_body_filter+49处,上面反汇编的代码只有一处就是ngx_http_stylecombine_body_filter+38,而ngx_http_stylecombine_body_filter+38处的je跳转指令取决于前一条指令test %rax,%rax对于EFLAG寄存器Z标志位的影响,如果rax为0,Z标志位为0,je指令跳转到ngx_http_stylecombine_body_filter+49。 所以只要单步执行下来到ngx_http_stylecombine_body_filter+35处看下rax的值,就能确定是否跳转。通过gdb指令单步跟踪发现rax确实为0:

(gdb) p $rax
$28 = 0

这就解释了为什么ngx_http_stylecombine_body_filter为什么总是提前执行ngx_http_next_body_filter退出。问题的重点是rax代表什么?为什么会是0?

问题的本质:

要追究问题的本质需要知道C语句

ctx = ngx_http_get_module_ctx(r, ngx_http_stylecombine_filter_module);

经过gcc编译之后到底翻译成了什么? C语言函数ngx_http_stylecombine_body_filter反汇编之后的第一条指令到ngx_http_stylecombine_body_filter+35处指令并不多,我们可以一一对照下分析下,

0x0000000000487b8b <ngx_http_stylecombine_body_filter+0>:    push   %r15
0x0000000000487b8d <ngx_http_stylecombine_body_filter+2>:   push   %r14
0x0000000000487b8f <ngx_http_stylecombine_body_filter+4>:   push   %r13
0x0000000000487b91 <ngx_http_stylecombine_body_filter+6>:   push   %r12
0x0000000000487b93 <ngx_http_stylecombine_body_filter+8>:   push   %rbp
0x0000000000487b94 <ngx_http_stylecombine_body_filter+9>:   push   %rbx
;以上是保存寄存器的值,这些寄存器在本次函数调用中有可能被使用到,
;需要保护起来,在函数退出的时候通过pop操作恢复回去。
0x0000000000487b95 <ngx_http_stylecombine_body_filter+10>:  sub    $0x68,%rsp
;这条指令开辟栈上空间用于保存函数的局部变量。
;也就是对于到这三条C语句 (rc, out和ctx指针占用的字节数在比0x68要小,
;       实际上gcc会可能会对齐局部变量加快CPU对于变量的访问速度,
;       另外对于整个函数使用的局部变量都会一次性分配好,ngx_http_stylecombine_body_filter有些局部变量在后面声明。)
; 395        int                   rc;                                       
; 396        ngx_chain_t                    out;
; 397        ngx_http_stylecombine_ctx_t  *ctx; 

0x0000000000487b99 <ngx_http_stylecombine_body_filter+14>:  mov    %rdi,%r14
0x0000000000487b9c <ngx_http_stylecombine_body_filter+17>:  mov    %rsi,%r13
;前文说到,这两条指令保存r和in到r14和r13寄存器。

结合前面的分析,也就是剩下三条指令和一句C语句需要分析。

0x0000000000487b9f <ngx_http_stylecombine_body_filter+20>:    mov    0x10(%rdi),%rdx
0x0000000000487ba3 <ngx_http_stylecombine_body_filter+24>:  mov    0x234c76(%rip),%rax        # 0x6bc820 <ngx_http_stylecombine_filter_module>
0x0000000000487baa <ngx_http_stylecombine_body_filter+31>:  mov    (%rdx,%rax,8),%rax;

很显然这三条指令是对应到ctx = ngx_http_get_module_ctx(r, ngx_http_stylecombine_filter_module);(后面可以看到这么说不全对)

当我调试到此处的时候,发现ngx_http_get_module_ctx其实是一个宏,这个宏的定义如下:

#define ngx_http_get_module_ctx(r, module)  (r)->ctx[module.ctx_index]

了解C语言的同学一定知道ctx是r的一个成员,对于汇编来说其实就是一个地址,

mov    0x10(%rdi),%rdx   
;rdi寄存器保存的是r的地址,0x10(%rdi)表示r的地址开始加16(0x10)
;由此可知ctx成员在r结构体中的偏移是16.

module.ctx_index用于在ctx中做索引,通过ngx_module_t的定义,我们知道ctx_index是ngx_module_t的首成员,

struct ngx_module_s {
ngx_uint_t            ctx_index;
ngx_uint_t            index;
.....

所以module和module.ctx_index的地址是相同的,这也是为什么gdb提示的是# 0x6bc820 <ngx_http_stylecombine_filter_module>

mov    0x234c76(%rip),%rax        # 0x6bc820 <ngx_http_stylecombine_filter_module>

有了r->ctx的地址(保存在rdx),和索引值module.ctx_index(保存在rax),接下来就是去取索引的内容:

mov    (%rdx,%rax,8),%rax
;rdx + rax * 8
;x86_64的地址长度为8个字节。

到此为止知道ngx_http_stylecombine_body_filter+35指令之前rax寄存器保存内容的来历。就是说(r)–>ctx[module.ctx_index]确实是为0, if语句的跳转没有出错。

等等,貌似少了什么东西,为什么在前面通过gdb打印ctx的值不为0,按照C语言的语句ctx = ngx_http_get_module_ctx(r, ngx_http_stylecombine_filter_module);ctx应该要是0才对!? 我认为这是由于gcc对C语言的宏进行展开之后,通过数据流分析和优化能发现if语句里面要判断的值其实是(r)–>ctx[module.ctx_index],不是局部变量ctx,从反汇编的结果也可以看出在ngx_http_stylecombine_body_filter+35处的test语句之前并没有ctx的赋值操作,如果ngx_http_get_module_ctx是一个函数而不是一个宏,ctx应该会是0(前提是编译器没有过度优化)。

这就能解释ctx为什么不为0,gdb打印出来ctx的值实际上是一个未初始化的栈上的值,也就是一个野值(0x989680),当天调试较晚困了没有发现。同时也解释了我前面说从ngx_http_stylecombine_body_filter+20开始的三条mov指令对应到ctx = ngx_http_get_module_ctx(r, ngx_http_stylecombine_filter_module)的说法其实是不全对的,因为ctx的赋值操作没有在这3条指令中。(ps:欢迎大家和我交流^–^)

再谈KeepAlive

我为什么要再谈KeepAlive

申请的域名到现在,除了一篇开篇博文到目前为止还没有一篇正式的文章。最近工作中遇到一个问题,想把它记录下来,场景是这样的:

nginx-lvs-client

从上图可以看出,用户通过Client访问的是LVS的VIP, VIP后端挂载的RealServer是Nginx服务器。 Client可以是浏览器也可以是一个客户端程序。一般情况下, 这种架构不会出现问题,但是如果Client端把请求发送给Nginx,Nginx的后端需要一段时间才能返回结果,超过1分30秒就会有问题,使用LVS作为负载均衡设备看到的现象就是1分30秒之后, Client和Nginx链接被断开,没有数据返回。 原因是LVS默认保持TCP的Session为90s,超过90s没有TCP报文在链接上传输,LVS就会给两端发送REST报文断开链接。LVS这么做的原因相信大家都知道一二,我所知道的原因主要有两点:

1.节省负载均衡设备资源,每一个TCP/UDP的链接都会在负载均衡设备上创建一个Session的结构,
  链接如果一直不断开,这种Session结构信息最终会消耗掉所有的资源,所以必须释放掉。
2.另外释放掉能保护后端的资源,如果攻击者通过空链接,链接到Nginx上,如果Nginx没有做合适
  的保护,Nginx会因为链接数过多而无法提供服务。

这种问题不只是在LVS上有,之前在商用负载均衡设备F5上遇到过同样的问题,F5的Session断开方式和LVS有点区别,F5不会主动发送REST给链接的两端,Session消失之后,当链接中一方再次发送报文时会接收到F5的REST, 之后的现象是再次发送报文的一端TCP链接状态已经断开,而另外一端却还是ESTABLISH状态。

知道是负载均衡设备原因之后,第一反应就是通过开启KeepAlive来解决。到此这个问题应该是结束了,但是我发现过一段时间总又有人提起KeepAlive的问题,甚至发现由于KeepAlive的理解不正确浪费了很多资源,原本能使用LVS的应用放在了公网下沉区,或者换成了商用F5设备(F5设备的Session断开时间要长一点,默认应该是5分钟)。所以我决定把我知道的KeepAlive知识点写篇博客分享出来。

为什么要有KeepAlive?

在谈KeepAlive之前,我们先来了解下简单TCP知识(知识很简单,高手直接忽略)。首先要明确的是在TCP层是没有“请求”一说的,经常听到在TCP层发送一个请求,这种说法是错误的。TCP是一种通信的方式,“请求”一词是事务上的概念,HTTP协议是一种事务协议,如果说发送一个HTTP请求,这种说法就没有问题。也经常听到面试官反馈有些面试运维的同学,基本的TCP三次握手的概念不清楚, 面试官问TCP是如何建立链接,面试者上来就说,假如我是客户端我发送一个请求给服务端,服务端发送一个请求给我。。。这种一听就知道对TCP基本概念不清楚。下面是我通过wireshark抓取的一个TCP建立握手的过程。(命令行基本上用TCPdump,后面我们还会用这张图说明问题):

tcp-session-create

现在我看只要看前3行,这就是TCP三次握手的完整建立过程,第一个报文SYN从发起方发出,第二个报文SYN,ACK是从被连接方发出,第三个报文ACK确认对方的SYN,ACK已经收到,如下图:

tcp-syn-synack-ack

但是数据实际上并没有传输,请求是有数据的,第四个报文才是数据传输开始的过程,细心的读者应该能够发现wireshark把第四个报文解析成HTTP协议,HTTP协议的GET方法和URI也解析出来,所以说TCP层是没有请求的概念,HTTP协议是事务性协议才有请求的概念,TCP报文承载HTTP协议的请求(Request)和响应(Response)。

现在才是开始说明为什么要有KeepAlive。 链接建立之后,如果应用程序或者上层协议一直不发送数据,或者隔很长时间才发送一次数据,当链接很久没有数据报文传输时如何去确定对方还在线,到底是掉线了还是确实没有数据传输,链接还需不需要保持,这种情况在TCP协议设计中是需要考虑到的。TCP协议通过一种巧妙的方式去解决这个问题,当超过一段时间之后,TCP自动发送一个数据为空的报文给对方,如果对方回应了这个报文,说明对方还在线,链接可以继续保持,如果对方没有报文返回,并且重试了多次之后则认为链接丢失,没有必要保持链接。

如何开启KeepAlive

KeepAlive并不是默认开启的,在Linux系统上没有一个全局的选项去开启TCP的KeepAlive。需要开启KeepAlive的应用必须在TCP的socket中单独开启。Linux Kernel有三个选项影响到KeepAlive的行为:

1.net.ipv4.tcp_keepalive_intvl = 75
2.net.ipv4.tcp_keepalive_probes = 9
3.net.ipv4.tcp_keepalive_time = 7200

tcp_keepalive_time的单位是秒,表示TCP链接在多少秒之后没有数据报文传输启动探测报文; tcp_keepalive_intvl单位是也秒,表示前一个探测报文和后一个探测报文之间的时间间隔,tcp_keepalive_probes表示探测的次数。

TCP socket也有三个选项和内核对应,通过setsockopt系统调用针对单独的socket进行设置:

TCP_KEEPCNT: 覆盖 tcp_keepalive_probes
TCP_KEEPIDLE: 覆盖  tcp_keepalive_time
TCP_KEEPINTVL: 覆盖  tcp_keepalive_intvl

举个例子,以我的系统默认设置为例,kernel默认设置的tcp_keepalive_time是7200s, 如果我在应用程序中针对socket开启了KeepAlive,然后设置的TCP_KEEPIDLE为60,那么TCP协议栈在发现TCP链接空闲了60s没有数据传输的时候就会发送第一个探测报文。

TCP KeepAlive和HTTP的Keep-Alive是一样的吗?

估计很多人乍看下这个问题才发现其实经常说的KeepAlive不是这么回事,实际上在没有特指是TCP还是HTTP层的KeepAlive,不能混为一谈。TCP的KeepAlive和HTTP的Keep-Alive是完全不同的概念。TCP层的KeepAlive上面已经解释过了。 HTTP层的Keep-Alive是什么概念呢? 在讲述TCP链接建立的时候,我画了一张三次握手的示意图,TCP在建立链接之后, HTTP协议使用TCP传输HTTP协议的请求(Request)和响应(Response)数据,一次完整的HTTP事务如下图:

http

各位看官请注意,这张图我简化了HTTP(Req)和HTTP(Resp),实际上的请求和响应需要多个TCP报文。从图中可以发现一个完整的HTTP事务,有链接的建立, 请求的发送,响应接收,断开链接这四个过程,早期通过HTTP协议传输的数据以文本为主,一个请求可能就把所有要返回的数据取到,但是,现在要展现一张完整的页面需要很多个请求才能完成,如图片,JS,CSS等,如果每一个HTTP请求都需要新建并断开一个TCP,这个开销是完全没有必要的,开启HTTP Keep-Alive之后,能复用已有的TCP链接,当前一个请求已经响应完毕,服务器端没有立即关闭TCP链接,而是等待一段时间接收浏览器端可能发送过来的第二个请求,通常浏览器在第一个请求返回之后会立即发送第二个请求,如果某一时刻只能有一个链接,同一个TCP链接处理的请求越多,开启KeepAlive能节省的TCP建立和关闭的消耗就越多。当然通常会启用多个链接去从服务器器上请求资源,但是开启了Keep-Alive之后,仍然能加快资源的加载速度。HTTP/1.1之后默认开启Keep-Alive, 在HTTP的头域中增加Connection选项。当设置为Connection:keep-alive表示开启,设置为Connection:close表示关闭。实际上HTTP的KeepAlive写法是Keep-Alive,跟TCP的KeepAlive写法上也有不同。 所以TCP KeepAlive和HTTP的Keep-Alive不是同一回事情。

Nginx的TCP KeepAlive如何设置

开篇提到我最近遇到的问题,Client发送一个请求到Nginx服务端,服务端需要经过一段时间的计算才会返回, 时间超过了LVS Session保持的90s,在服务端使用Tcpdump抓包,本地通过wireshark分析显示的结果如第二副图所示,第5条报文和最后一条报文之间的时间戳大概差了90s。在确定是LVS的Session保持时间到期的问题之后,我开始在寻找Nginx的TCP KeepAlive如何设置,最先找到的选项是keepalive_timeout,从同事那里得知keepalive_timeout的用法是当keepalive_timeout的值为0时表示关闭keepalive,当keepalive_timeout的值为一个正整数值时表示链接保持多少秒,于是把keepalive_timeout设置成75s,但是实际的测试结果表明并不生效。显然keepalive_timeout不能解决TCP层面的KeepAlive问题,实际上Nginx涉及到keepalive的选项还不少,Nginx通常的使用方式如下:

nginx

从TCP层面Nginx不仅要和Client关心KeepAlive,而且还要和Upstream关心KeepAlive, 同时从HTTP协议层面,Nginx需要和Client关心Keep-Alive,如果Upstream使用的HTTP协议,还要关心和Upstream的Keep-Alive,总而言之,还比较复杂。所以搞清楚TCP层的KeepAlive和HTTP的Keep-Alive之后,就不会对于Nginx的KeepAlive设置错。我当时解决这个问题时候不确定Nginx有配置TCP keepAlive的选项,于是我打开Ngnix的源代码,在源代码里面搜索TCP_KEEPIDLE,相关的代码如下:

 519 #if (NGX_HAVE_KEEPALIVE_TUNABLE)
 520                 
 521         if (ls[i].keepidle) { 
 522             if (setsockopt(ls[i].fd, IPPROTO_TCP, TCP_KEEPIDLE,
 523                            (const void *) &ls[i].keepidle, sizeof(int))
 524                 == -1)
 525             {
 526                 ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
 527                               "setsockopt(TCP_KEEPIDLE, %d) %V failed, ignored",
 528                               ls[i].keepidle, &ls[i].addr_text);
 529             }
 530         }

从代码的上下文我发现TCP KeepAlive可以配置,所以我接着查找通过哪个选项配置,最后发现listen指令的so_keepalive选项能对TCP socket进行KeepAlive的配置。

so_keepalive=on|off|[keepidle]:[keepintvl]:[keepcnt]
on表示开启
off表示关闭
有些系统提供跟精确的控制,比如linux:
    keepidle表示等待时间,keepintvl表示探测报的发送间隔,keepcnt表示探测报文发送的次数。

以上三个参数只能使用一个,不能同时使用, 比如so_keepalive=on, so_keepalive=off或者so_keepalive=30s::(表示等待30s没有数据报文发送探测报文)。通过设置listen 80,so_keepalive=60s::之后成功解决Nginx在LVS保持长链接的问题,避免了使用其他高成本的方案。在商用负载设备上如果遇到类似的问题同样也可以通过这种方式解决。

参考资料

《TCP/IP协议详解VOL1》—强烈建议对于网络基本知识不清楚同学有空去看下。

http://tldp.org/HOWTO/html_single/TCP-Keepalive-HOWTO/#overview

http://nginx.org/en/docs/http/ngx_http_core_module.html

Nginx Source code: https://github.com/alibaba/tengine

记录技术点滴,积累铸就高度

记录技术点滴

很多时候想把自己在平常工作,学习中碰到的技术,遇到的难题,总结的解决方案写下来。在我的环境当中,我算是比较喜欢分享的,分享到自己都感觉到不好意思,可能是技术不够严谨,也可能是技术的分享确实乏味,有些可能跟工作没有直接关系。所以没能继续,总结得也不够多。我看到很多技术上很好的人他们乐于分享,无论是中国的还是外国的。不奢望我总结的内容能够有他们的高度,能有多少的读者,但是我还是希望能把自己的一些总结或者心得写下来,哪怕自己查阅也好。

积累铸就高度

每天上班下班,每天看书学习,也没看到自己的技术又多大的进步。我觉得其实技术和人的性格有类似性,虽然都是从事的IT行业,各个人的技术特长还是不一样的。虽然我知道自己的技术不怎么样,但是从我做过的技术分享来看,有些挺不错的效果。有时分享的内容可能就是听众百思不得其解的一个问题,有时分享的内容,尽管不是很严谨,内容也比较虚,但还是有听众会觉得不错。更好的是分享能促进自己对于问题的理解,交到志同道合的朋友!这是我觉得有必要开博的重要原因。

对本博客的要求

我自己其实也在其他好的网站上申请过博客空间,写过一两篇文章,但是都没有坚持下来,久而久之也不知道自己有几个博客。挺遗憾的。这次重新开始,没有选择在其他网站的blog上继续,一来是同事手上刚好有一台机器资源,尽管现在还没有确定能不能完全长期使用,所以为了保证博客的可用性,我先在github上做。然后花了一点时间在godaddy.net上申请了一个两年的域名,花了差不多170元RMB。有人说花钱的东西会心痛,会心痛就能坚持,希望如此。

这次对自己的要求是在这两年的域名有效期坚持写不少于8篇技术性的文章。加油!