shell后台并发执行实践

shell如何在后台执行

1.nohup命令
通常我们都是远程登录linux终端,而当我们退出终端时在之前终端运行的程序都会终止,有时候先想要退出终端也要程序继续执行这时nohup就登场了。nohup命令可以将程序以忽略挂起信号的方式运行起来,被运行的程序的输出信息将不会显示到终端。
nohup command > myout.file 2>&1 &

2.&后台执行
在命令后面加 & 可以让程序在后台执行
command &

3.Ctrl + z
当一个程序正在执行并且占用当前终端时我们同时按下 Ctrl + z ,这样就会把正在执行的前台程序放到后台挂起。

并发执行

1.正常执行

#!/bin/bash
Njob=10    #任务总数
for ((i=0; i<$Njob; i++)); do
{
	echo  "progress $i is sleeping for 1 seconds zzz…"
	sleep  1
}
done
echo -e "time-consuming: $SECONDS seconds"    #显示脚本执行耗时

执行结果

progress 0 is sleeping for 1 seconds zzz…
progress 1 is sleeping for 1 seconds zzz…
progress 2 is sleeping for 1 seconds zzz…
progress 3 is sleeping for 1 seconds zzz…
progress 4 is sleeping for 1 seconds zzz…
progress 5 is sleeping for 1 seconds zzz…
progress 6 is sleeping for 1 seconds zzz…
progress 7 is sleeping for 1 seconds zzz…
progress 8 is sleeping for 1 seconds zzz…
progress 9 is sleeping for 1 seconds zzz…
-e time-consuming: 10 seconds

2.并发后台执行

#!/bin/bash
Njob=10
for ((i=0; i<$Njob; i++)); do
    echo  "progress $i is sleeping for 3 seconds zzz…"
    sleep  3 &       #循环内容放到后台执行
done
wait      #等待循环结束再执行wait后面的内容
echo -e "time-consuming: $SECONDS seconds"    #显示脚本执行耗时

执行结果

progress 0 is sleeping for 3 seconds zzz…
progress 1 is sleeping for 3 seconds zzz…
progress 2 is sleeping for 3 seconds zzz…
progress 3 is sleeping for 3 seconds zzz…
progress 4 is sleeping for 3 seconds zzz…
progress 5 is sleeping for 3 seconds zzz…
progress 6 is sleeping for 3 seconds zzz…
progress 7 is sleeping for 3 seconds zzz…
progress 8 is sleeping for 3 seconds zzz…
progress 9 is sleeping for 3 seconds zzz…
-e time-consuming: 3 seconds

这种方式从功能上实现了使用shell脚本并行执行多个循环进程,但是它缺乏控制机制。

for设置了Njob次循环,同一时间Linux就触发Njob个进程一起执行。假设for里面执行的是scp,在没有pam_limits和cgroup限制的情况下,很有可能同一时刻过多的scp任务会耗尽系统的磁盘IO、连接数、带宽等资源,导致正常的业务受到影响。

一个应对办法是在for循环里面再嵌套一层循环,这样同一时间,系统最多只会执行内嵌循环限制值的个数的进程。不过还有一个问题,for后面的wait命令以循环中最慢的进程结束为结束(水桶效应)。如果嵌套循环中有某一个进程执行过程较慢,那么整体这一轮内嵌循环的执行时间就等于这个“慢”进程的执行时间,整体下来脚本的执行效率还是受到影响的。

分批并行的方式并发执行

#!/bin/bash
NQ=3
num=5
for ((i=0; i<$NQ; i++)); do
     for ((j=0; j<$num; j++)); do
         echo  "progress $i is sleeping for 3 seconds zzz…"
        sleep 3 &
     done
     wait
 done
#等待循环结束再执行wait后面的内容
echo -e "time-consuming: $SECONDS    seconds"    #显示脚本执行耗时

执行结果

 progress 0 is sleeping for 3 seconds zzz…
 progress 0 is sleeping for 3 seconds zzz…
 progress 0 is sleeping for 3 seconds zzz…
 progress 0 is sleeping for 3 seconds zzz…
 progress 0 is sleeping for 3 seconds zzz…
 progress 1 is sleeping for 3 seconds zzz…
 progress 1 is sleeping for 3 seconds zzz…
 progress 1 is sleeping for 3 seconds zzz…
 progress 1 is sleeping for 3 seconds zzz…
 progress 1 is sleeping for 3 seconds zzz…
 progress 2 is sleeping for 3 seconds zzz…
 progress 2 is sleeping for 3 seconds zzz…
 progress 2 is sleeping for 3 seconds zzz…
 progress 2 is sleeping for 3 seconds zzz…
 progress 2 is sleeping for 3 seconds zzz…
-e time-consuming: 9    seconds

3.使用模拟队列来控制进程数量

要控制后台同一时刻的进程数量,需要在原有循环的基础上增加管理机制。

一个方法是以for循环的子进程PID做为队列元素,模拟一个限定最大进程数的队列(只是一个长度固定的数组,并不是真实的队列)。队列的初始长度为0,循环每创建一个进程,就让队列长度+1。当队列长度到达设置的并发进程限制数之后,每隔一段时间检查队列,如果队列长度还是等于限制值,那么不做操作,继续轮询;如果检测到有并发进程执行结束了,那么队列长度-1,轮询检测到队列长度小于限制值后,会启动下一个待执行的进程,直至所有等待执行的并发进程全部执行完。

#!/bin/bash
Njob=15 #任务总数
Nproc=5 #最大并发进程数

function PushQue {      #将PID值追加到队列中
           Que="$Que $1"
           Nrun=$(($Nrun+1))
}

function GenQue {       #更新队列信息,先清空队列信息,然后检索生成新的队列信息
           OldQue=$Que
           Que=""; Nrun=0
           for PID in $OldQue; do
                 if [[ -d /proc/$PID ]]; then
                        PushQue $PID
                 fi
           done
}

function ChkQue {       #检查队列信息,如果有已经结束了的进程的PID,那么更新队列信息
           OldQue=$Que
           for PID in $OldQue; do
                 if [[ ! -d /proc/$PID ]];   then
                 GenQue; break
                 fi
           done
}

for ((i=1; i<=$Njob; i++)); do
           echo "progress $i is sleeping for 3 seconds zzz…"
           sleep 3 &
           PID=$!
           PushQue $PID
           while [[ $Nrun -ge $Nproc ]]; do          # 如果Nrun大于Nproc,就一直ChkQue
                 ChkQue
                 sleep 0.1
           done
done
wait
echo -e "time-consuming: $SECONDS   seconds"    #显示脚本执行耗时

执行结果

progress 1 is sleeping for 3 seconds zzz…
progress 2 is sleeping for 3 seconds zzz…
progress 3 is sleeping for 3 seconds zzz…
progress 4 is sleeping for 3 seconds zzz…
progress 5 is sleeping for 3 seconds zzz…
progress 6 is sleeping for 3 seconds zzz…
progress 7 is sleeping for 3 seconds zzz…
progress 8 is sleeping for 3 seconds zzz…
progress 9 is sleeping for 3 seconds zzz…
progress 10 is sleeping for 3 seconds zzz…
progress 11 is sleeping for 3 seconds zzz…
progress 12 is sleeping for 3 seconds zzz…
progress 13 is sleeping for 3 seconds zzz…
progress 14 is sleeping for 3 seconds zzz…
progress 15 is sleeping for 3 seconds zzz…
-e time-consuming: 3   seconds

这种使用队列模型管理进程的方式在控制了后台进程数量的情况下,还能避免个别“慢”进程影响整体耗时的问题:

4.使用fifo管道特性来控制进程数量

管道是内核中的一个单向的数据通道,同时也是一个数据队列。具有一个读取端与一个写入端,每一端对应着一个文件描述符。
命名管道即FIFO文件,通过命名管道可以在不相关的进程之间交换数据。FIFO有路径名与之相关联,以一种特殊设备文件形式存在于文件系统中。

FIFO有两种用途:

• FIFO由shell使用以便数据从一条管道线传输到另一条,为此无需创建临时文件,常见的操作cat file|grep keyword就是这种使用方式;
• FIFO用于客户进程-服务器进程程序中,已在客户进程与服务器进程之间传送数据,下面的例子将使用这种方式。

根据FIFO文件的读规则(参考http://www.cnblogs.com/yxmx/articles/1599187.html),如果有进程写打开FIFO,且当前FIFO内没有数据,对于设置了阻塞标志的读操作来说,将一直阻塞状态。

利用这一特性可以实现一个令牌机制。设置一个行数等于限定最大进程数Nproc的fifo文件,在for循环中设置创建一个进程时先read一次fifo文件,进程结束时再write一次fifo文件。如果当前子进程数达到限定最大进程数Nproc,则fifo文件为空,后续执行的并发进程被读fifo命令阻塞,循环内容被没有触发,直至有某一个并发进程执行结果并做写操作(相当于将令牌还给池子)。

需要注意的是,当并发数较大时,多个并发进程即使在使用sleep相同秒数模拟时,也会存在进程调度的顺序问题,因而并不是按启动顺序结束的,可能会后启动的进程先结束。

#!/bin/bash

Njob=15 #任务总数

Nproc=5 #最大并发进程数

mkfifo ./fifo.$$ && exec   9<>  ./fifo.$$     #通过文件描述符777访问fifo文件

for ((i=0; i<$Nproc; i++)); do  #向fifo文件先填充等于Nproc值的行数
  echo  "init time add $i" >&9
done
for ((i=0; i<$Njob; i++)); do
{
  read  -u  9             #从fifo文件读一行
  echo  "progress $i is sleeping for 3 seconds zzz…"
  sleep  3
  echo  "real time add $(($i+$Nproc))"  1>&9 #sleep完成后,向fifo文件重新写入一行
} &
done
wait
echo -e "time-consuming: $SECONDS seconds"
rm -f ./fifo.$$

执行结果

progress 0 is sleeping for 3 seconds zzz…
progress 1 is sleeping for 3 seconds zzz…
progress 2 is sleeping for 3 seconds zzz…
progress 3 is sleeping for 3 seconds zzz…
progress 4 is sleeping for 3 seconds zzz…
progress 5 is sleeping for 3 seconds zzz…
progress 6 is sleeping for 3 seconds zzz…
progress 8 is sleeping for 3 seconds zzz…
progress 12 is sleeping for 3 seconds zzz…
progress 13 is sleeping for 3 seconds zzz…
progress 9 is sleeping for 3 seconds zzz…
progress 11 is sleeping for 3 seconds zzz…
progress 14 is sleeping for 3 seconds zzz…
progress 10 is sleeping for 3 seconds zzz…
progress 7 is sleeping for 3 seconds zzz…
-e time-consuming: 10 seconds

原文地址:
Shell脚本实现并发多进程
Shell脚本并发执行

OKR 填写指南

一、OKR概述
OKR是一个目标管理工具。其中O指Objective,是团队或个人的工作目标;KR指Key Result,是一系列可以衡量的关键结果,用来判断Objective是否达成。在作业帮,OKR的制定和共享,是公司、团队和个人制定任务,对齐目标,协调和集中精力的重要手段。

二、OKR执行要点
工作目标设置应该激进,要使自己和团队感受到压力。
关键结果要容易打分衡量,不要模棱两可。
除保密事项外,OKR尽可能的公开,方便互相了解在忙些什么工作。
写好OKR,要求严格的聚焦,只写最重要的目标,不罗列堆砌。
OKR并不是绩效考核工具,但应该是自我检测工作成果的重要工具。
OKR不是一个共享的工作清单或者to-do list,而是一个管理精力,自我规划的重要工具。
没有经过沟通和对齐的OKR,相当于没有写,OKR的对齐方包括你的上级、同事和你承接需求(提出需求)的协同方。

三、设定激进、聚焦的目标

在每个OKR周期中,公司从CEO到全体员工都会制定自己的OKR,通常是三四个Objectives,每个Objective有3个左右的Key Results。这些OKR既有从上到下目标分解而来的,也有从基层收集的各种意见演化而来,所以OKR的制定是一个循环迭代和讨论的过程。

我们希望每一个人都制定激进的目标,这些目标看上去刚好处于 “这个周期完不成” 的边缘。事实证明,制定高目标,有助于我们取得优于普通水准的成果。 每个高目标都需要全情投入。所以OKR制定的另一方面,是严格地聚焦到重点方向,不贪多。但在选定的重点方向上,力求保证目标达成。

一些设定Objective的窍门:

选三五个目标即可,宁缺勿滥。太多目标,工作容易失去焦点,团队也会疲于应付。
描述最终状态,比如“上线xx功能”,“获得10%的市场份额”。
常规动作不要写进OKR,例如“继续推进”、“保持行业地位”。如果确是长期重要工作,要思考如何拆解成合理的周期OKR,而不是不经思考地写“继续做”。
团队leader是团队的大脑,更要注意排优先级和授权。

四、可衡量的Key Result

关键结果(Key Result,或简称KR)是用来评价目标是否达成的。考虑到这个功能,对关键结果最大的要求就是容易衡量,且直接支持目标的达成。

一些设定Key Result的窍门:

每个目标定3个左右KR即可,不多刻意多写。
注意,KR是用来衡量Objective的达成程度的,问一下自己“这个KR和相应的Objective有直接的支撑关系吗”。
要描述产出,而不是动作。产出是指类似于“发表一篇论文”这样的表述,而动作是指“进行研究并撰写论文”这样的表述。所以当一个KR中出现 “参与”、“分析”、“辅助”这样的词汇,或者描述过程的动词特别多时,可能就有问题了。
用客观、外部可观测和不模糊的表述。最好是你的同事也能够准确地对你的KR进行打分,这说明KR足够清晰。
五、常见错误
执行不好的OKR,不但对公司、团队和个人没有帮助,反而可能造成团队安于现状,甚至方向混淆、内耗。所以我们要避免常见的OKR执行错误,比如下面这些:

堆砌不重要的目标
OKR常常被写得很长、很多,很难用两三句阐述其中的重点。往往这样的OKR包含大量的常规工作。常规工作不是不可以写,而是要判断,这项工作需要我和团队付出额外的努力才能达成吗。因为OKR要求激进,“担心有可能完不成”,相当多的常规目标是达不到这个要求的。这里有两点需要澄清:一、OKR不是工作量的衡量工具,并且我们应当主要关注OKR对业务带来的实际效果,所以不必在OKR中堆砌工作量;二、避免堆砌,要求每个人有判断力,自觉抛弃低优先级的目标和低价值的目标,而将精力和资源放到高优先级目标上。可以问自己几个问题,“这件事不做有什么影响吗”,“这件事做了有实际的业务收益吗”。

只写一个单词
写OKR时容易偷懒,只写一个单词,比如“收入” “DAU” “xx项目”。这样的写法,既没有说明最终要达到的状态,也很难客观打分。同时在沟通和对齐的时候,其他同事也很难看懂。类似的,“其他项目”、“重点项目”也是不可取的Objective,因为项目真的重要话,应该明确说出来要达到什么目标;而归为“其他”的目标,通常可能并不重要。

给自己留余量
如果历史上所有的OKR都轻松达成,可能定目标的时候就不够有雄心。

六、沟通、对齐和进度更新
沟通和对齐有几个重要的作用。首先是在重要的方向上,配合团队和上下级形成合力,而不要往不同方向使力。其次,OKR的制定是一个迭代过程,从草稿到定稿,需要方方面面的意见输入。再次,要了解配合团队定OKR时的激进程度,判断如果配合团队的OKR只能完成0.5分,会不会影响自己的工作。另一方面,应该鼓励经常更新OKR进度,既是对自己的提醒鞭策,又是向同事同步信息的好方式。

查理·芒格:如何过上痛苦的生活?

在大学毕业典礼上,诞生过很多著名的演讲,如斯蒂夫·乔布斯 2005 年在斯坦福毕业典礼上的演讲,如 JK·罗琳 2008 年在哈佛大学毕业典礼上的演讲。大多数毕业演讲者会选择描述如何获得幸福的生活,而今天的作者查理·芒格(Charlie Thomas Munger)使用 逆向思维的原则,令人信服地从反面阐述了一名毕业生 如何才能过上痛苦的生活。至于那些宁愿继续保持无知和郁闷的读者,建议你们千万别阅读这篇讲稿。

痛苦人生的药方

在我听过的 20 次哈佛学校的毕业演讲中,哪次曾让我希望它再长些呢?这样的演讲只有约翰尼·卡森的那一次,他详述了保证痛苦人生的卡森药方。所以呢,我决定重复卡森的演讲,但以更大的规模,并加上我自己的药方。毕竟,我比卡森演讲时岁数更大,同一个年轻的有魅力的幽默家相比,我失败的次数更多,痛苦更多,痛苦的方式也更多。我显然很有资格进一步发挥卡森的主题。

那时卡森说他无法告诉毕业的同学如何才能得到幸福,但能够根据个人经验,告诉他们如何保证自己过上痛苦的生活。卡森给的确保痛苦生活的处方包括:

  1. 为了改变心情或者感觉而使用化学物质;

  2. 妒忌,以及

  3. 怨恨。

我现在还能想起来当时卡森用言之凿凿的口气说,他一次又一次地尝试了这些东西,结果每次都变得很痛苦。

要理解卡森为痛苦生活所开处方的第一味药物(使用化学物质)比较容易。我想补充几句。我年轻时最好的朋友有四个,他们非常聪明、正直和幽默,自身条件和家庭背景都很出色。其中两个早已去世,酒精是让他们早逝的一个因素;第三个人现在还醉生梦死地活着——假如那也算活着的话。

虽然易感性因人而异,我们任何人都有可能通过一个开始时难以察觉直到堕落之力强大到无法冲破的细微过程而染上恶瘾。不过呢,我活了 60 年,倒是没有见过有谁的生活因为害怕和避开这条诱惑性的毁灭之路而变得更加糟糕。

妒忌,和令人上瘾的化学物质一样,自然也能获得导致痛苦生活的大奖。早在遭到摩西戒律的谴责之前,它就已造成了许多大灾难。如果你们希望保持妒忌对痛苦生活的影响,我建议你们千万别去阅读塞缪尔·约翰逊(编者注:Samuel Johnson,1709——1784,英国作家,文学研究者和批评家)的任何传记,因为这位虔诚基督徒的生活以令人向往的方式展示了超越妒忌的可能性和好处。

就像卡森感受到的那样,怨恨对我来说也很灵验。如果你们渴望过上痛苦的生活,我找不到比它更灵的药方可以推荐给你们了。约翰逊说得好,他说生活本已艰辛得难以下咽,何必再将它塞进怨恨的苦涩果皮里呢。

对于你们之中那些想得到痛苦生活的人,我还要建议你们别去实践狄斯雷利的权宜之计,它是专为那些无法彻底戒掉怨恨老习惯的人所设计的。在成为伟大的英国首相的过程中,迪斯雷利学会了不让复仇成为行动的动机,但他也保留了某种发泄怨恨的办法,就是将那些敌人的名字写下来,放到抽屉里。然后时不时会翻看这些名字,自得其乐地记录下世界是怎样无须他插手就使他的敌人垮掉的。

本杰明·迪斯雷利(Benjamin Disraeli,1804 – 1881,英国保守党领袖,两度出任英国首相)

查理·芒格的四味药

好啦,卡森开的处方就说到这里。接下来是芒格另开的四味药。

第一,要反复无常,不要虔诚地做你正在做的事。只要养成这个习惯,你们就能够绰绰有余地抵消你们所有优点共同产生的效应,不管那种效应有多么巨大。如果你们喜欢不受信任并被排除在对人类贡献最杰出的人群之外,那么这味药物最适合你们。养成这个习惯,你们将会永远扮演寓言里那只兔子的角色,只不过跑得比你们快的不再只是一只优秀的乌龟,而是一群又一群平庸的乌龟,甚至还有些拄拐杖的平庸乌龟。

我必须警告你们,如果不服用我开出的第一味药,即使你们最初的条件并不好,你们也可能会难以过上痛苦的日子。我有个大学的室友,他以前患有严重的阅读障碍症,现在也是。但他算得上我认识的人中最可靠的。他的生活到目前为止很美满,拥有出色的太太和子女,掌管着某个数十亿美元的企业。如果你们想要避免这种传统的、主流文化的、富有成就的生活,却又坚持不懈地做到为人可靠,那么就算有其他再多的缺点,你们这个愿望恐怕也会落空。

说到「到目前为止很美满」这样一种生活,我忍不住想在这里引用克洛伊斯的话来再次强调人类生存状况那种「到目前为止」的那一面。克洛伊斯曾经是世界上最富裕的国王,后来沦为敌人的阶下囚,就在被活活烧死之前,他说:「哎呀,我现在才想起历史学家梭伦说过的那句话,『在生命没有结束之前,没有人的一生能够被称为是幸福的。』」

我为痛苦生活开出的第二味药是,尽可能从你们自身的经验获得知识,尽量别从其他人成功或失败的经验中广泛地吸取教训,不管他们是古人还是今人。这味药肯定能保证你们过上痛苦的生活,取得二流的成就。

只要看看身边发生的事情,你们就能明白拒不借鉴别人的教训所造成的后果。人类常见的灾难全都毫无创意——酒后驾车导致的身亡,鲁莽驾驶引起的残疾,无药可治的性病,加入毁形灭性的邪教的那些聪明的大学生被洗脑后变成的行尸走肉,由于重蹈前人显而易见的覆辙而导致的生意失败,还有各种形式的集体疯狂等等。你们若要寻找那条通往因为不小心、没有创意的错误而引起真正的人生麻烦的道路,我建议你们牢牢记住这句现代谚语:「人生就像悬挂式滑翔,起步没有成功就完蛋啦。」

避免广泛吸取知识的另一种做法是,别去钻研那些前辈的最好成果。这味药的功效在于让你们得到尽可能少的教育。

如果我再讲一个简短的历史故事,或许你们可以看得更清楚,从而更有效地过上与幸福无缘的生活。从前有个人,他勤奋地掌握了前人最优秀的成果,尽管开始研究分析几何的时候他的基础并不好,学得非常吃力。最终,他本人取得的成就引起了众人的瞩目,他是这样评价他自己的成果的:

「如果说我比其他人看得更远,那是因为我站在巨人的肩膀上。」

这人的骨灰如今埋在威斯敏斯特大教堂里,他的墓碑上有句异乎寻常的墓志铭:

「这里安葬着永垂不朽的艾萨克·牛顿爵士。」

我为你们的痛苦生活开出的第三味药是,当你们在人生的战场上遭遇第一、第二或者第三次严重的失败时,就请意志消沉,从此一蹶不振吧。因为即使是最幸运、最聪明的人,也会遇到许许多多的失败,这味药必定能保证你们永远地陷身在痛苦的泥沼里。请你们千万要忽略爱比克泰德(编者注:爱比克泰德出生在希拉波利斯城的奴隶家庭,而且患有终身残疾,他认为所有人都应该完全自由地掌握自己的生活,也应该与自然和谐相处。)亲自撰写的、恰如其分的墓志铭中蕴含的教训:「此处埋着爱比克泰德,一个奴隶,身体残疾,极其穷困,蒙受诸神的恩宠。」

为了让你们过上头脑混乱、痛苦不堪的日子,我所开的最后一味药是,请忽略小时候人们告诉我的那个乡下人故事。曾经有个乡下人说:「要是知道我会死在哪里就好啦,那我将永远不去那个地方。」大多数人和你们一样,嘲笑这个乡下人的无知,忽略他那朴素的智慧。如果我的经验有什么借鉴意义的话,那些热爱痛苦生活的人应该不惜任何代价避免应用这个乡下人的方法。若想获得失败,你们应该将这种乡下人的方法,也就是卡森在演讲中所用的方法,贬低得愚蠢之极、毫无用处。

「以避免失败为目标而成长」

卡森采用的研究方法是把问题反过来想。就是说要解出 X,得先研究如何才能得到非 X。伟大的代数学家雅各比用的也是卡森这种办法,众所周知,他经常重复一句话:「反过来想,总是反过来想。」雅各比知道事物的本质是这样的,许多难题只有在逆向思考的时候才能得到最好的解决。例如,当年几乎所有人都在试图修正麦克斯韦的电磁定律,以便它能够符合牛顿的三大运动定律,然而爱因斯坦却转了个 180 度大弯,修正了牛顿的定律,让其符合麦克斯韦的定律,结果他发现了相对论。

作为一个公认的传记爱好者,我认为假如查尔斯·罗伯特·达尔文是哈佛学校 1986 届毕业班的学生,他的成绩大概只能排到中等。然而现在他是科学史上的大名人。如果你们希望将来碌碌无为,那么千万不能以达尔文为榜样。

达尔文能够取得这样的成就,主要是因为他的工作方式。这种方式有悖于所有我列出的痛苦法则,而且还特别强调逆向思考:他总是致力于寻求证据来否定他已有的理论,无论他对这种理论有多么珍惜,无论这种理论是多么得之不易。与之相反,大多数人早年取得成就,然后就越来越拒绝新的、证伪性的信息,目的是让他们最初的结论能够保持完整。他们变成了菲利普·威利所评论的那类人:「他们固步自封,满足于已有的知识,永远不会去了解新的事物。」

达尔文的生平展示了乌龟如何可以在极端客观态度的帮助下跑到兔子前面去。这种态度能够帮助客观的人最后变成「蒙眼拼驴尾」游戏中惟一那个没有被遮住眼睛的玩家。

如果你们认为客观态度无足轻重,那么你们不但忽略了来自达尔文的训诲,也忽略了来自爱因斯坦的教导。爱因斯坦说他那些成功的理论来自「好奇、专注、毅力和自省」。他所说的自省,就是不停地试验与推翻他自己深爱的想法。

最后,尽可能地减少客观性,这样会帮助你减少获得世俗好处所需作出的让步以及所要承受的负担,因为客观态度并不只对伟大的物理学家和生物学家有效。它也能够帮助伯米吉地区的管道维修工更好地工作。因此,如果你们认为忠实于自己就是永远不改变你们年轻时的所有观念,那么你们不仅将会稳步地踏上通往极端无知的道路,而且还将走向事业中不愉快的经历给你带来的所有痛苦。

这次类似于说反话的演讲应该以类似于说反话的祝福来结束。这句祝语的灵感来自伊莱休·鲁特引用过的那首讲小狗去多佛的儿歌:「一步又一步,才能到多佛。」我祝福 1986 届毕业班的同学:

在座各位,愿你们在漫长的人生中日日以避免失败为目标而成长。■

节选自《穷查理宝典》(中信出版社)第四章,第一讲

JAVA 锁优化 笔记

高效并发是JDK一个非常重要的改进,HotSpot虚拟机开发团队花费大量的精力去实现各种锁的优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)等,这些技术都是为了线程间更高效地共享数据,以及解决竞争问题,提高程序执行效率。

自旋锁和自适应自旋
共享数据的锁定状态只会持续很短的时间,为了这一段时间挂起和恢复线程并不值得。如果机器有多个处理器,可以让多个线程同时并行执行,我们可以让后面请求锁的那个线程“稍等一段时间”,但是不放弃CPU执行时间,看看持有锁的线程是否很快就会释放掉锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这个技术就是所谓的自旋锁。

自旋锁在jdk1.4.2中已经引入,只不过默认是关闭状态,可以使用-XX:+UseSpinning参数开启,在jdk1.6中已经默认开启了。自旋等待不能代替阻塞,自旋要求有多处理器,自旋虽然避免了线程切换的开销,但是需要占用处理器时间,所以会白白消耗CPU资源,如果自旋时间非常长,就会带来资源的浪费。所以,自旋等待的时间必须有一定的限度,如果自旋次数超过了限定次数还没有成功获取锁,就应当使用传统的方式去挂起线程。自旋次数默认为10次,用户可以使用参数-XX:PreBlockSpin 配置。

jdk1.6引入自适应自旋锁,自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间和锁拥有者的状态来决定的。如果同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么虚拟机会认为这次自旋也很有可能再次成功,进而允许自旋等待相对更长的时间。反之,如果对于某个锁,自旋很少获取到过,那么在以后获取这个锁的时候就会省略自旋的过程。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况会预测的越来越准。

锁消除
锁消除的主要判定依据来源于逃逸分析的数据支持,判断堆上的数据都不会逃逸出去从而被其他线程访问,那就可以吧他们当做栈上数据对待,认为数据是线程私有的,加锁操作就可以忽略。具体可以看StringBuffer 在一个方法内部 定义,作用域只在方法内,锁会安全的消除掉,编译后所有的同步操作就会直接执行。

锁粗化
如果一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体内,就算没有线程竞争,频繁的互斥同步操作也会导致性能损耗。如果虚拟机探测到有这种操作,会把加锁同步的范围扩展到整个操作的序列外,只进行一次加锁和解锁。

轻量级锁
轻量级锁并不是用来替代重量级锁的,本意是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能损耗。主要依赖虚拟机对象头数据,进行CAS操作来实现加锁和解锁操作。[备注:后续文章会详细介绍JAVA 对象的内存布局]。对象头[Mark Word]中有2bit存储锁标志位,1bit固定为0,其他状态(轻量级锁定,重量级锁定,GC标记,可偏向)
01 ——> 未锁定状态 –> 存储 对象哈希码 对象分代年龄
00 –> 轻量级锁定 –> 指向锁记录指针
10 –> 重量级锁定 –> 指向重量级锁的指针
11 –> GC标记 –> 不记录任何消息
01 –> 可偏向 –> 偏向线程ID 偏向时间戳 对象分代年龄

代码进入同步快,如果同步对象没有锁定(01 状态),虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced 前缀,即 Displaced Mark Word),然后虚拟机使用CAS操作尝试把对象的Mark Word 更新指向 Lock Record的指针。如果操作成功线程就拥有了该对象的锁,并把对象Mark Word 的标志位修改为”00″,表示对象处于轻量级锁定状态,如果更新失败,虚拟机首先检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经拥有这个对象的锁,那就可以直接进入同步块进行执行。否则,这个锁对象已经被其他线程抢占了。如果有2个以上线程竞争,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志位变为”10″,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待的线程也会进入阻塞状态。

解锁过程也是通过CAS操作的,如果对象的Mark Word 仍然指向着线程的锁记录,那就用CAS 操作把对象当前的Mark Word 和 线程中复制的Displaced Mark Word 替换回来。如果替换成功整个同步过程就成功了,如果替换失败,说明其他线程正在尝试获取该锁,那就再释放锁的同时唤醒其他被挂起的线程。

轻量级锁提升性能主要依据:“对于绝大部分的锁,整个同步周期内都是不存在竞争的”,这只是一个经验数据。如果存在锁的竞争,出了额外的CAS操作,还有互斥量的开销,轻量级锁会比传统的锁性能差。

偏向锁

轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS都不做了。

偏向锁的“偏”,是偏向的意思,锁会偏向于第一个获取到它的线程,如果下面的操作,没有其他线程去获取该锁,则持有偏向锁的线程将永远不会同步。

启用偏向锁:-XX:+UseBiasedLocking jdk1.6默认开启。
第一次获取锁需要把状态标志设置 01 (偏向模式) ,同时使用CAS操作把当前线程ID记录在Mark Word中,CAS操作成功,持有偏向锁的线程每次进入这个锁的同步块时,虚拟机没有任何同步操作(Locking,UnLocking,Update Mark Word)
当其他线程去获取这个锁的时候,偏向模式结束,根据锁对象是否处于锁定状态,撤销偏向后恢复到未锁定(01)或者轻量级锁(00),后续和轻量级锁执行流程一样。

偏向锁可以提高带有同步但是无竞争的程序性能。它同样是带有权衡收益(Trade Off)的优化,也就是说,并不一定对程序有利,如果程序锁竞争比较激烈,那偏向就是多余的,具体问题具体分析,有时候关闭偏向锁性能反而提升了。

LockSupport

concurrent包是基于AQS (AbstractQueuedSynchronizer)框架的,AQS框架借助于两个类:

  • Unsafe(提供CAS操作)
  • LockSupport(提供park/unpark操作)

因此,LockSupport非常重要。
两个重点
(1)操作对象
归根结底,LockSupport.park()和LockSupport.unpark(Thread thread)调用的是Unsafe中的native代码:

//LockSupport中
public static void park() {
     UNSAFE.park(false, 0L);
}
//LockSupport中
public static void unpark(Thread thread) {
        if (thread != null)
            UNSAFE.unpark(thread);
    }

Unsafe类中的对应方法:

    //park
    public native void park(boolean isAbsolute, long time);
    
    //unpack
    public native void unpark(Object var1);

park函数是将当前调用Thread阻塞,而unpark函数则是将指定线程Thread唤醒。

与Object类的wait/notify机制相比,park/unpark有两个优点:
以thread为操作对象更符合阻塞线程的直观定义
操作更精准,可以准确地唤醒某一个线程(notify随机唤醒一个线程,notifyAll唤醒所有等待的线程),增加了灵活性。

(2)关于“许可”

在上面的文字中,我使用了阻塞和唤醒,是为了和wait/notify做对比。

其实park/unpark的设计原理核心是“许可”:park是等待一个许可,unpark是为某线程提供一个许可。
如果某线程A调用park,那么除非另外一个线程调用unpark(A)给A一个许可,否则线程A将阻塞在park操作上。

有一点比较难理解的,是unpark操作可以再park操作之前。
也就是说,先提供许可。当某线程调用park时,已经有许可了,它就消费这个许可,然后可以继续运行。这其实是必须的。考虑最简单的生产者(Producer)消费者(Consumer)模型:Consumer需要消费一个资源,于是调用park操作等待;Producer则生产资源,然后调用unpark给予Consumer使用的许可。非常有可能的一种情况是,Producer先生产,这时候Consumer可能还没有构造好(比如线程还没启动,或者还没切换到该线程)。那么等Consumer准备好要消费时,显然这时候资源已经生产好了,可以直接用,那么park操作当然可以直接运行下去。如果没有这个语义,那将非常难以操作。

但是这个“许可”是不能叠加的,“许可”是一次性的。
比如线程B连续调用了三次unpark函数,当线程A调用park函数就使用掉这个“许可”,如果线程A再次调用park,则进入等待状态。
继续阅读“LockSupport”

Java ReadWriteLock读写锁的使用

本文将提供Java中的ReadWriteLock和ReentrantReadWriteLock的示例。JDK 1.5中已经引入了ReadWriteLock和ReentrantReadWriteLock。ReentrantReadWriteLock是ReadWriteLock接口的实现,而ReadWriteLock扩展了Lock接口。ReentrantReadWriteLock是具有可重入性的ReadWriteLock的实现。ReentrantReadWriteLock具有关联的读写锁,可以重新获取这些锁。在本文,我们将通过完整的示例讨论ReadWriteLock和ReentrantReadWriteLock。

Lock
JDK 1.5中引入了java.util.concurrent.locks.Lock接口。Lock可以代替使用同步方法,并将有助于更有效的锁定系统。Lock在多线程环境中用于共享资源。Lock作用的方式是,任何线程必须必须首先获得锁才能访问受锁保护的共享资源。一次只有一个线程可以获取锁,一旦其工作完成,它将为队列中其他线程解锁资源。ReadWriteLock是扩展的接口Lock。

ReadWriteLock
JDK 1.5中引入了java.util.concurrent.locks.ReadWriteLock接口。ReadWriteLock是用于读取和写入操作的一对锁。如果没有写锁定请求,则多个线程可以同时获取读锁定请求。如果线程获得了对资源的写锁,则任何线程都无法获得对该资源的其他读或写锁。ReadWriteLock在读操作比写操作更频繁的情况下,效率更高,因为可以由多个线程同时为共享资源获取读锁定。

ReentrantReadWriteLock
JDK 1.5中引入了java.util.concurrent.locks.ReentrantReadWriteLock类。ReentrantReadWriteLock是的实现ReadWriteLock。我们将讨论ReentrantReadWriteLock的一些主要属性。

Acquisition order [获取顺序]
ReentrantReadWriteLock可以使用公平和非公平模式。默认是不公平的。当以非公平初始化时,读锁和写锁的获取的顺序是不确定的。非公平锁主张竞争获取,可能会延缓一个或多个读或写线程,但是会比公平锁有更高的吞吐量。当以公平模式初始化时,线程将会以队列的顺序获取锁。当当前线程释放锁后,等待时间最长的写锁线程就会被分配写锁;或者有一组读线程组等待时间比写线程长,那么这组读线程组将会被分配读锁。

Reentrancy [重入]

什么是可重入锁,不可重入锁呢?”重入”字面意思已经很明显了,就是可以重新进入。可重入锁,就是说一个线程在获取某个锁后,还可以继续获取该锁,即允许一个线程多次获取同一个锁。比如synchronized内置锁就是可重入的,如果A类有2个synchornized方法method1和method2,那么method1调用method2是允许的。显然重入锁给编程带来了极大的方便。假如内置锁不是可重入的,那么导致的问题是:1个类的synchornized方法不能调用本类其他synchornized方法,也不能调用父类中的synchornized方法。与内置锁对应,JDK提供的显示锁ReentrantLock也是可以重入的。

Lock downgrading [锁降级]
ReentrantReadWriteLock可以从写锁降级为读锁。这意味着,如果线程获得了写锁,则可以将其锁从写锁降级为读锁。顺序为:首先获取写锁,执行写操作,然后获取读锁,然后解锁写锁,然后在读操作之后最终解锁读锁。从读锁升级到写锁是不行的。
继续阅读“Java ReadWriteLock读写锁的使用”

CyclicBarrier 使用不当导致死锁问题

先上代码:


import java.util.concurrent.*;

public class App {

	public static ExecutorService pool = Executors.newFixedThreadPool(5);
	public static ExecutorService pool2 = Executors.newCachedThreadPool();

	public static void main(String[] args) {
		// 使用 CyclicBarrier 出现死锁问题
		// new GenTaskUseBarrier(pool, 10).start();
		// 使用 CachedThreadPool 解决 CyclicBarrier 死锁问题
		// new GenTaskUseBarrier(pool2, 10).start();
		// 使用 CountDownLatch 解决 CyclicBarrier 死锁问题
		// new GenTaskUseCountDown(pool, 10).start();

		// 模拟5个并发共享一个线程池
		// new MockConcurrence(pool, 5).start();
	}
}

class MockConcurrence extends Thread {
	ExecutorService pool;
	int count;

	public MockConcurrence(ExecutorService pool, int count) {
		super();
		this.pool = pool;
		this.count = count;
	}

	@Override
	public void run() {
		for (int i = 0; i < count; i++) {
			new GenTaskUseBarrier(pool, 5, false).start();
		}
	}
}

class GenTaskUseCountDown extends Thread {

	ExecutorService pool;

	int taskSize;

	public GenTaskUseCountDown(ExecutorService pool, int taskSize) {
		this.pool = pool;
		this.taskSize = taskSize;
	}

	@Override
	public void run() {
		CountDownLatch latch = new CountDownLatch(taskSize);
		for (int i = 0; i < taskSize; i++) {
			pool.submit(new MyTask(latch));
		}
		try {
			latch.await();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		pool.shutdown();
		System.out.println("All task done!");
	}
}

class GenTaskUseBarrier extends Thread {

	ExecutorService pool;

	int taskSize;

	boolean autoClose = true;

	public GenTaskUseBarrier(ExecutorService pool, int taskSize) {
		this.pool = pool;
		this.taskSize = taskSize;
	}

	public GenTaskUseBarrier(ExecutorService pool, int taskSize, boolean autoClose) {
		this(pool, taskSize);
		this.autoClose = autoClose;
	}

	@Override
	public void run() {
		CyclicBarrier barrier = new CyclicBarrier(taskSize, new Runnable() {
			@Override
			public void run() {
				if (autoClose) {
					pool.shutdown();
				}
				System.out.println("All task done!");
			}
		});
		for (int i = 0; i < taskSize; i++)
			pool.submit(new MyTask(barrier));
	}
}

class MyTask extends Thread {

	CyclicBarrier barrier;

	CountDownLatch latch;

	public MyTask(CyclicBarrier barrier) {
		this.barrier = barrier;
	}

	public MyTask(CountDownLatch latch) {
		this.latch = latch;
	}

	@Override
	public void run() {
		try {
			System.out.println("线程" + Thread.currentThread().getName() + "正在执行同一个任务");
			// 以睡眠来模拟几个线程执行一个任务的时间
			Thread.sleep(1000);
			System.out.println("线程" + Thread.currentThread().getName() + "执行任务完成,等待其他线程执行完毕");
			// 用来挂起当前线程,直至所有线程都到达barrier状态再同时执行后续任务;
			if (barrier != null) {
				barrier.await();
			}
			if (latch != null) {
				latch.countDown();
			}
		} catch (InterruptedException e) {
			e.printStackTrace();
		} catch (BrokenBarrierException e) {
			e.printStackTrace();
		}
		System.out.println("所有线程写入完毕");

	}

}

输出结果:

线程pool-1-thread-3正在执行同一个任务
线程pool-1-thread-4正在执行同一个任务
线程pool-1-thread-2正在执行同一个任务
线程pool-1-thread-1正在执行同一个任务
线程pool-1-thread-5正在执行同一个任务
线程pool-1-thread-2执行任务完成,等待其他线程执行完毕
线程pool-1-thread-3执行任务完成,等待其他线程执行完毕
线程pool-1-thread-4执行任务完成,等待其他线程执行完毕
线程pool-1-thread-1执行任务完成,等待其他线程执行完毕
线程pool-1-thread-5执行任务完成,等待其他线程执行完毕

//卡死在这个地方

原因分析:
1.提交了10个任务,但是只有5个调度线程、每次只能执行一个解析任务;
2.前面5个任务执行后由于调用了 barrier.await 被阻塞了,需要等待其他两个任务都达到栅栏状态;
3.前面5个任务的线程被阻塞了,导致没有空闲的调度线程去执行另外两个任务;
4.前面5个任务等待其他两个任务的栅栏唤醒,而其他5个任务则等待第一个任务的线程资源,从而进入死锁状态。

解决方案 [修改Main方法注释 可以逐个测试]:
1、调整线程池调度线程个数,提交多少个任务开多少个资源,如果并发调用的时候共享同一个线程池调度非常容易出现问题,一定要小心
2、使用 CachedThreadPool,或者自定义线程池
3、更换协作工具类为 CountDownLatch,将主线程阻塞直到所有的解析任务都被执行完成。

缓存的使用和设计

原文地址 [本文整理发布]

缓存的一些基本常识

  • Cache(缓存): 从cpu的一级和二级缓存、Internet的DNS、到浏览器缓存都可以看做是一种缓存。(存贮数据(使用频繁的数据)的临时地方,因为取原始数据的代价太大了,所以我可以取得快一些)
  • Cache hit(缓存命中) Cahe miss(缓存未命中)

  • 缓存算法:缓存容量超过预设,如何踢掉“无用”的数据。例如:LRU(Least Recently Used) FIFO(First Input First Output)Least Frequently Used(LFU) 等等

  • System-of-Record(真实数据源): 例如关系型数据库、其他持久性系统等等。 也有英文书叫做authority data(权威数据)

  • serialization-and-deserialization(序列化与反序列化):可以参考:序列化与反序列化(美团工程师写的,非常棒的文章)
    serialization-and-deserialization

  • Scale Up (垂直扩容) 和 Scale out (水平扩容), 驴拉车,通常不是把一头驴养壮(有极限),而通常是一群驴去拉(当然每个个体也不能太差)。

    64258e75-ad29-32ef-ac55-45d95278ff0a

  • Write-through 和 write-behind
    154e49f4-f223-3b4f-9346-fa378dd2efd1

  • 阿姆而达定律:用于计算缓存加速比

  • LocalCache(独立式): 例如Ehcache、BigMemory Go
    (1) 缓存和应用在一个JVM中。
    (2) 缓存间是不通信的,独立的。
    (3) 弱一致性。

  • Standalone(单机):
    (1) 缓存和应用是独立部署的。
    (2) 缓存可以是单台。(例如memcache/redis单机等等)
    (3) 强一致性
    (4) 无高可用、无分布式。
    (5) 跨进程、跨网络

  • Distributed(分布式):例如Redis-Cluster, memcache集群等等
    (1) 缓存和应用是独立部署的。
    (2) 多个实例。(例如memcache/redis等等)
    (3) 强一致性或者最终一致性
    (4) 支持Scale Out、高可用。
    (5) 跨进程、跨网络

  • Replicated(复制式): 缓存数据时同时存放在多个应用节点的,数据复制和失效的事件以同步或者异步的形式在各个集群节点间传播。(也是弱一致性)这种用的不太多。

  • 数据层访问速度
    082dfcc7-1a06-3116-886f-055d4af98cf8
    继续阅读“缓存的使用和设计”

Memcached 总结

总结 memcached 教程 [必看]

Memcached 二三事 [版权归 原文链接,本文修改一些引用资源地址]

实际应用Memcached时,我们遇到的很多问题都是因为不了解其内存分配机制所致,下面就让我们以此为开端来开始Memcached之旅吧!官方wiki

为了规避内存碎片问题,Memcached采用了名为SlabAllocator的内存分配机制。内存以Page为单位来分配,每个Page分给一个特定长度的Slab来使用,每个Slab包含若干个特定长度的Chunk。实际保存数据时,会根据数据的大小选择一个最贴切的Slab,并把数据保存在对应的Chunk中。如果某个Slab没有剩余的Chunk了,系统便会给这个Slab分配一个新的Page以供使用,如果没有Page可用,系统就会触发LRU机制,通过删除冷数据来为新数据腾出空间,这里有一点需要注意的是:LRU不是全局的,而是针对Slab而言的。

一个Slab可以有多个Page,这就好比在古代一个男人可以娶多个女人;一旦一个Page被分给某个Slab后,它便对Slab至死不渝,犹如古代那些贞洁的女人。但是女人的数量毕竟是有限的,所以一旦一些男人娶得多了,必然另一些男人就只剩下咽口水的份儿,这在很大程度上增加了社会的不稳定因素,于是乎我们要解放女性。

好在Memcached已经意识到解放女性的重要性,新版本中Page可以调配给其它的Slab:

memcached -o slab_reassign,slab_automove

换句话说:女人可以改嫁了!这方面,其实Memcached的儿子Twemcache革命得更彻底,他甚至写了一篇大字报,以事实为依据,痛斥老子的无能,有兴趣的可以继续阅读:Random Eviciton vs Slab Automove

了解Memcached内存使用情况的最佳工具是:Memcached-tool。如果我们发现某个Slab的Evicted不为零,则说明这个Slab已经出现了LRU的情况,这通常是个危险的信号,但也不能一概而论,需要结合Evict_Time来做进一步判断。Replacing the cache replacement algorithm in memcached

在Memcached的使用过程中,除了会遇到内存分配机制相关的问题,还有很多稀奇古怪的问题等着你呢,下面我选出几个有代表性的问题来逐一说明:

Cache失效后的拥堵问题

通常我们会为两种数据做Cache,一种是热数据,也就是说短时间内有很多人访问的数据;另一种是高成本的数据,也就说查询很很耗时的数据。当这些数据过期的瞬间,如果大量请求同时到达,那么它们会一起请求后端重建Cache,造成拥堵问题,就好象在北京上班做地铁似的,英文称之为:stampeding herd,老外这里的用词还是很形象的。

一般有如下几种解决思路可供选择:

首先,我们可以主动更新Cache。前端程序里不涉及重建Cache的职责,所有相关逻辑都由后端独立的程序(比如CRON脚本)来完成,但此方法并不适应所有的需求。

其次,我们可以通过加锁来解决问题。以PHP为例,伪代码大致如下:

<?php
function query()
{
    $data = $cache->get($key);

    if ($cache->getResultCode() == Memcached::RES_NOTFOUND) {
        if ($cache->add($lockKey, $lockData, $lockExpiration)) {
            $data = $db->query();
            $cache->set($key, $data, $expiration);
            $cache->delete($lockKey);
        } else {
            sleep($interval);
            $data = query();
        }
    }

    return $data;
}
?>

不过这里有一个问题,代码里用到了sleep,也就是说客户端会卡住一段时间,就拿PHP来说吧,即便这段时间非常短暂,也有可能堵塞所有的FPM进程,从而使服务中断。于是又有人想出了柔性过期的解决方案,所谓柔性过期,指的是设置一个相对较长的过期时间,或者干脆不再直接设置数据的过期时间,取而代之的是把真正的过期时间嵌入到数据中去,查询时再判断,如果数据过期就加锁重建,如果加锁失败,不再sleep,而是直接返回旧数据,以PHP为例,伪代码大致如下:

<?php
function query()
{
    $data = $cache->get($key);

    if (isset($data['expiration']) && $data['expiration'] < $now) {
        if ($cache->add($lockKey, $lockData, $lockExpiration)) {
            $data = $db->query();
            $data['expiration'] = $expiration;
            $cache->set($key, $data);
            $cache->delete($lockKey);
        }
    }

    return $data;
}
?>

问题到这里似乎已经圆满解决了,且慢!还有一些特殊情况没有考虑到:设想一下服务重启;或者某个Cache里原本没有的冷数据因为某些情况突然转换成热数据;又或者由于LRU机制导致某些键被意外删除,等等,这些情况都可能会让上面的方法失效,因为在这些情况里就不存在所谓的旧数据,等待用户的将是一个空页面。

好在我们还有Gearman这根救命稻草。当需要更新Cache的时候,我们不再直接查询数据库,而是把任务抛给Gearman来处理,当并发量比较大的时候,Gearman内部的优化可以保证相同的请求只查询一次后端数据库,以PHP为例,伪代码大致如下:

<?php

function query()
{
    $data = $cache->get($key);
    //说明:如果多个并发请求的$unique参数一样,那么实际上Gearman只会请求一次。
    if ($cache->getResultCode() == Memcached::RES_NOTFOUND) {
        $data = $gearman->do($function, $workload, $unique);
        $cache->set($key, $data, $expiration);
    }

    return $data;
}
?>

Multiget的无底洞问题

Facebook在Memcached的实际应用中,发现了Multiget无底洞问题,具体表现为:出于效率的考虑,很多Memcached应用都已Multiget操作为主,随着访问量的增加,系统负载捉襟见肘,遇到此类问题,直觉通常都是通过增加服务器来提升系统性能,但是在实际操作中却发现问题并不简单,新加的服务器好像被扔到了无底洞里一样毫无效果。

为什么会这样?让我们来模拟一下案发经过,看看到底发生了什么:

我们使用Multiget一次性获取100个键对应的数据,系统最初只有一台Memcached服务器,随着访问量的增加,系统负载捉襟见肘,于是我们又增加了一台Memcached服务器,数据散列到两台服务器上,开始那100个键在两台服务器上各有50个,问题就在这里:原本只要访问一台服务器就能获取的数据,现在要访问两台服务器才能获取,服务器加的越多,需要访问的服务器就越多,所以问题不会改善,甚至还会恶化。

不过,作为被告方,Memcached官方开发人员对此进行了辩护:

请求多台服务器并不是问题的症结,真正的原因在于客户端在请求多台服务器时是并行的还是串行的!问题是很多客户端,包括Libmemcached在内,在处理Multiget多服务器请求时,使用的是串行的方式!也就是说,先请求一台服务器,然后等待响应结果,接着请求另一台,结果导致客户端操作时间累加,请求堆积,性能下降。

如何解决这个棘手的问题呢?只要保证Multiget中的键只出现在一台服务器上即可!比如说用户名字(user:foo:name),用户年龄(user:foo:age)等数据在散列到多台服务器上时,不应按照完整的键名(user:foo:name和user:foo:age)来散列的,而应按照特殊的键(foo)来散列的,这样就保证了相关的键只出现在一台服务器上。以PHP的 Memcached客户端为例,有getMultiByKey和setMultiByKey可供使用。

Nagle和DelayedAcknowledgment的延迟问题

老实说,这个问题和Memcached没有半毛钱关系,任何网络应用都有可能会碰到这个问题,但是鉴于很多人在写Memcached程序的时候会遇到这个问题,所以还是拿出来聊一聊,在这之前我们先来看看Nagle和DelayedAcknowledgment的含义:

先看看Nagle:

假如需要频繁的发送一些小包数据,比如说1个字节,以IPv4为例的话,则每个包都要附带40字节的头,也就是说,总计41个字节的数据里,其中只有1个字节是我们需要的数据。为了解决这个问题,出现了Nagle算法。它规定:如果包的大小满足MSS,那么可以立即发送,否则数据会被放到缓冲区,等到已经发送的包被确认了之后才能继续发送。通过这样的规定,可以降低网络里小包的数量,从而提升网络性能。

再看看DelayedAcknowledgment:

假如需要单独确认每一个包的话,那么网络中将会充斥着无数的ACK,从而降低了网络性能。为了解决这个问题,DelayedAcknowledgment规定:不再针对单个包发送ACK,而是一次确认两个包,或者在发送响应数据的同时捎带着发送ACK,又或者触发超时时间后再发送ACK。通过这样的规定,可以降低网络里ACK的数量,从而提升网络性能。

Nagle和DelayedAcknowledgment虽然都是好心,但是它们在一起的时候却会办坏事。下面我们举例说说Nagle和DelayedAcknowledgment是如何产生延迟问题的:

客户端需要向服务端传输数据,传输前数据被分为ABCD四个包,其中ABC三个包的大小都是MSS,而D的大小则小于MSS,交互过程如下:

首先,因为客户端的ABC三个包的大小都是MSS,所以它们可以耗无障碍的发送,服务端由于DelayedAcknowledgment的存在,会把AB两个包放在一起来发送ACK,但是却不会单独为C包发送ACK。

接着,因为客户端的D包小于MSS,并且C包尚未被确认,所以D包不会立即发送,而被放到缓冲区里延迟发送。

最后,服务端触发了超时阈值,终于为C包发送了ACK,因为不存在未被确认的包了,所以即便D包小于MSS,也总算熬出头了,可以发送了,服务端在收到了所有的包之后就可以发送响应数据了。

说到这里,假如你认为自己已经理解了这个问题的来龙去脉,那么我们尝试改变一下前提条件:传输前数据被分为ABCDE五个包,其中ABCD四个包的大小都是MSS,而E的大小则小于MSS。换句话说,满足MSS的完整包的个数是偶数个,而不是前面所说的奇数个,此时又会出现什么情况呢?答案我就不说了,留给大家自己思考。

知道了问题的原委,解决起来就简单了:我们只要设置socket选项为TCP_NODELAY即可,这样就可以禁用Nagle,以PHP为例:

<?php
$memcached->setOption(Memcached::OPT_TCP_NODELAY, true);
?>

如果大家意犹未尽,可以继续浏览:TCP Performance problems caused by interaction between Nagle’s Algorithm and Delayed ACK。

一次Memcached使用问题

收到群里反馈很多PHP请求执行超时,服务已经不可用。

查看服务器监控数据

发现 CPU 负载 在那段时间一直爬坡。 查询fpm slow 日志 发现请求都卡在了 memcache get 的方法上面。

发现出问题的请求有一个方法 频繁的读写一个key 这个 key 保存的内容 内存已经大于1MB, 一秒内有 500 多个请求,同时读写会导致memcache 的性能急剧下降。

自己写代码重现这个问题:
php 脚本 test-memcached.php:

<?php
$time_start = microtime(true);

$ac = new Memcached();
$ac->addServer('localhost', 11211);

#$ac->setOption(Memcached::OPT_SOCKET_SEND_SIZE, 1024*512);
#$ac->setOption(Memcached::OPT_SOCKET_RECV_SIZE, 1024*512);
#$ac->setOption(Memcached::OPT_RECV_TIMEOUT, 5);
#$ac->setOption(Memcached::OPT_SEND_TIMEOUT, 5);

$data = [];

for($i = 0; $i<30000; $i++){
  $data[] = md5($i);
}

$r = $ac->set('key', $data, 3600);
$set_time_end = microtime(true);
$set_time = $set_time_end - $time_start;
echo "Set Key Done in $set_time seconds \n";
$result = $ac->get('key');
$read_time_end = microtime(true);
$read_time = $read_time_end - $time_start;
echo "Read key Done in $read_time seconds \n";
$time_end = microtime(true);
$time = $time_end - $time_start;
echo "Did nothing in $time seconds\n";
?>

shell 测试脚本:

#!/bin/bash
date
for i in `seq 1 300`
do
{
 php test-memcached.php
 sleep 1
}&
done
wait #等待执行完成
date

分别修改 memcache key 保存内容的大小得到结果:

A:

for($i = 0; $i<30000; $i++){
  $data[] = md5($i);
}

B:

for($i = 0; $i<3; $i++){
  $data[] = md5($i);
}

测试结果:

A:

Did nothing in 18.769073009491 seconds
Did nothing in 14.950340032578 seconds
Did nothing in 19.823235034943 seconds
Did nothing in 20.014719009399 seconds
Did nothing in 20.894814014435 seconds
Did nothing in 20.827455997467 seconds
Did nothing in 15.345555067062 seconds
Did nothing in 18.984731197357 seconds
Did nothing in 21.08841586113 seconds

B:

Did nothing in 0.39238500595093 seconds
Did nothing in 0.42710494995117 seconds
Did nothing in 0.34245300292969 seconds
Did nothing in 0.084210872650146 seconds
Did nothing in 0.41426110267639 seconds
Did nothing in 0.12554693222046 seconds
Did nothing in 0.10487604141235 seconds
Did nothing in 0.60212993621826 seconds
Did nothing in 0.054439067840576 seconds
Did nothing in 0.11286902427673 seconds
Did nothing in 0.69583892822266 seconds
Did nothing in 0.14684200286865 seconds
Did nothing in 0.082473993301392 seconds
Did nothing in 0.34351587295532 seconds
Did nothing in 0.10698294639587 seconds


看结果可以得知 但 memcache 保存的内容变大的时候速度会非常慢,所以使用memcache 的时候要注意缓存值得大小。

memcache 缓存值最大大小是1M 引用 Oracle 网站的一个 FAQ

The default maximum object size is 1MB. In memcached 1.4.2 and later, you can change the maximum size of an object using the -I command line option.

For versions before this, to increase this size, you have to re-compile memcached. You can modify the value of the POWER_BLOCK within the slabs.c file within the source.

In memcached 1.4.2 and higher, you can configure the maximum supported object size by using the -I command-line option. For example, to increase the maximum object size to 5MB:

$ memcached -I 5m

If an object is larger than the maximum object size, you must manually split it. memcached is very simple: you give it a key and some data, it tries to cache it in RAM. If you try to store more than the default maximum size, the value is just truncated for speed reasons.

出现服务不可用的一个重要原因也有写缓存的次数和读缓存次数一样多。

最后修改测试脚本 依然保存很大的缓存值,但是只写入一次,测试并发的读取,基本都可以在1秒左右的读取到数据。

结论:
1.使用memcache的要注意缓存内容的大小,不要保存太大的内容在一个key上
2.缓存是读的次数远远大于写的次数,如果代码中发现写缓存的次数和读缓存的次数一样的时候要提高警惕
3.千万不要在数据库事务未提交的时候删除缓存

QA:
1.为啥使用MD5字符串?
因为memcache会自动压缩内容,如果用普通重复字符串测试效果不明显,线上出现问题的场景也是MD5串的缓存值,可以开启和关闭压缩选项?

测试代码下载