注: 此系列内容来自网络,未能查到原作者。感觉不错,在此分享。
pprof使用流程:https://time.geekbang.org/column/article/410205 https://time.geekbang.org/column/article/408529
pprof(堆、哪个方法占用cpu时间多,耗时、协程数、系统线程数、阻塞耗时block、互斥锁耗时mutex) 。go tool pprof 连接进行pprof采集,默认采集30秒。pprof可分析go程序cpu占用过高问题或系统线程数过多cpu负载高。
采样图分析:采样图中,矩形框面积越大,说明这个函数的累积采样时间越大。如果一个函数分析采样图中的矩形框面积很大,这时候我们就要认真分析了,因为很可能这个函数就有需要优化性能的地方。
火焰图分析:每一列代表一个调用栈,每一个格子代表一个函数。纵轴展示了栈的深度,按照调用关系从上到下排列。最下面的格子代表采样时,正在占用 CPU 的函数。调用栈在横向会按照字母排序,并且同样的调用栈会做合并,所以一个格子的宽度越大,说明这个函数越可能是瓶颈。
耗时分析(终端交互):top命令默认是按flat%排序的。做性能分析时,先按照cum来排序,top -cum,可以直观地看到哪个函数总耗时最多,然后参考该函数的本地采样时间和调用关系。判断是该函数性能耗时多,还是它调用的函数耗时多。本地采样时间占比很小,但是累积采样时间占比很高,这个函数耗时多是因为调用了其他函数,它们自身几乎没有耗时。然后用`list 方法名`命令查看具体的内部耗时情况。
内存分析:内存分析同上,本地采样内存占比很小,但是累积采样内存占比很高,这个函数多是因为调用的其他函数费内存,它们自身几乎没有内存消耗。
go trace :可以分析GC问题,根据GC反查到哪个方法在大量生成内存触发GC。 但如果您想跟踪运行缓慢的函数,或找到CPU时间花费在哪,这个工具就不合适了。您应该使用go tool pprof,它能显示在每个函数中花费的CPU时间的百分比。 go tool trace更适合找出程序在一段时间内正在做什么,而不是总体上的开销。
go程序启动流程:
初始化并绑定m0与g0,m0是主线程,程序启动必然会有个主线程m0,g0负责调度:调用schedule()函数;创建P,绑定m0和p0,先创建GOMAXPROCS个P,存在sched空闲链表(Pidle);m0的g0会创建一个指向runtime.main()的g,并放到p0的本地队列;runtime.main():启动sysmon线程、启动GC协程、执行init(各种init函数)、执行 main.main 函数;新G将唤醒Pidle里的P,以更好地分发工作。P会找一个自旋状态的M/唤醒睡眠的M/创建M与之关联,然后切换g0进行schedule调度。
golang调度:runnext > 本地队列 > 全局队列 > 网络 > (从其他P的本地队列中)偷取
1、从本地队列拿,2、没有从全局队列里拿一半回本地队列,3、从网络轮询器里拿并Inject到全局队列,4、从别的P偷一半放到本地,注:每个P每61次都会从全局队列里拿一个,防全局队列里的G饿死。
newm:创建一个工作线程,调用mstart,再调用minit和mstartm0做信号初始化工作(注册信号抢占函数),最后调用schedule()。每个M都有一个schedule()做死循环的用g0进行G的调度。当G运行结束时,会先放到p.gFree,最多64个,满了放一半到sche.gFree空闲链表里,当再go fun时会先从gFree里拿旧的G,没有再创建新的G对象。
schedule()函数:一开始获取的_g_ 是g0来的,从g0上获取对应的m到p拿到G后,调用gogo 从g0栈切换到G栈后执行任务,执行完后调用goexit到mcall 把G栈切换回g0栈,最后调用goexit0把G放回gFree。然后从头再来,获取_g_拿到g0循环这样处理。g0的作用是协助调度的协程。g->g0 10us、findG 20-100us、 g0->next G 10us
gopark的作用:https://blog.csdn.net/u010853261/article/details/85887948
解除当前G和M的绑定关系,将当前G状态机切换为_Gwaiting;调用一次schedule(),调度器P发起一轮新的调度;
mcall()的作用:保存当前协程信息(PC/SP存到g->sched),调用goready时能恢复现场; 当前线程从g堆栈切换到g0堆栈;在g0的堆栈上执行函数fn(g);gopark里的park_m是在切换到g0堆栈上后调度schedule(),schdeule()可以从g0开始进行G的调度;
goready的作用:将G的状态机切换到_Grunnable,然后加入到P的调度器的本地队列,等待P进行调度。
对于mainG,执行完用户定义的main函数的所有代码后,直接调用exit(0)退出整个进程,很霸道,不管其他G是否处理完成。
对于普通G在执行完代码,先是跳转到提前设置好的 goexit 函数的第二条指令,然后调用 runtime.goexit1,接着调用 mcall(goexit0),会切换到 g0 栈,运行 goexit0 函数,清理 G 的一些字段,并将其添加到 G 缓存池(gFree)里,然后进入 schedule 调度循环。到这里,普通 goroutine 才算完成使命。
Sysmon监控线程:
处于 _Prunning 或者 _Psyscall 状态的P,如果上一次触发调度的时间已经过去了 10ms,我们会通过 runtime.preemptone 抢占当前处理器。
处于_Psyscall 状态的P,当系统调用超过10ms,且队列有其他G要处理或不存在空闲处理器,Sysmon会调用runtime.handoffp让出P的使用权,把M和G独立出去运行。注意:抢占P的使用权和让出P的使用权是不一样的。注意系统调用时线程池耗尽问题,Go里M最多1w个。
1.13依赖栈增长检测代码方式:编译器在有明显栈消耗的函数头部插入一些检测代码,用g.stackguard0判断是否需要进行栈增长,如果g.stackguard0被设置为特殊标识runtime.stackPreempt便不会执行栈增长而去执行一次schedule()。空的for循环并没有调用函数,就没机会执行栈增长检测代码,他并不知道GC在等待他让出。
1.14基于信号的抢占调度:在创建一个工作线程时,会调用mstart,调用minit做信号初始化(注册信号抢占函数),发生抢占时会调用preemptone做信号异步抢占,preemptone->preemptM->signalM(向指定M发送sigPreemt信号)->SYS_tgkill。收到信号后会调用asyncPreempt2调mcall和gopreempt_m
2分钟触发次GC。若刚开始生成大量堆内存,GC后的剩余内存变多 GOGC为100,则1G变2G才触发,2G变4G才触发。所以要定期2分钟清理。
周期性扫描网络轮询器,把监听到数据的goroutine注入到全局队列
golang Mutex同步原语: https://my.oschina.net/u/142293/blog/5012739
互斥锁在正常情况下是把G切换至_Gwaiting,等待锁的持有者唤醒协程,把G gopark放到semaroot的sudog链表里挂着,解锁时会调用goready等待P调度,协程锁是基于线程锁的,可能会陷入内核挂起。读写锁是写优先于读的,防读锁过多导致写一直阻塞,读多写少RWMutex。写多读少mutex就行。
semaRoot(semtable会有251个semaRoot平衡树,每一个sync.Mutex的sema地址对应一个semaRoot,相同的锁的sudog由链表串起来) semaRoot则是队列结构体,内部是堆树,把和当前G关联的sudog放到semaRoot里,然后把G的状态改为_Gwaiting,调用调度器执行别的G,此时当前G就停止执行了。一直到被调度器重新调度执行,会首先释放sudog然后再去执行别的代码逻辑,semaRoot里有runtime.mutex线程锁的,要保障在面临多线程并发时,数据同步的问题。
sync.Mutex与runtime.mutex区别,Mutex挂起协程,不一定阻塞线程。mutex是挂起线程,调度下一个线程执行。sync.Mutex=>gopark(),runtime.mutex=>futex()。
sync.Mutex是设计给goroutine的,但goroutine是在用户空间实现的,但内核只能看见一组线程。协程同步的基础是线程同步,只是可能自旋不用挂起线程了,临界区内完成等待队列这类敏感数据的操作就行了。就是说runtime.mutex是线程锁,其底层是futex系统调用,但加锁时间往往很短。注意goparkUnlock是在这里解锁线程锁的。
golang defer原理:https://zhuanlan.zhihu.com/p/261840318
defer语句在当前G结构体的g._defer链表把defer结构体串起来,新defer是推入链表头部的。在有defer的函数末尾会插入runtime.deferreturn函数来调用defer链;
函数return执行流程:1.先给返回值赋值(匿名和有名返回值),2.当前G的g._defer链表的头开始执行defer结构里指向的函数,3.包裹函数return返回。
defer在1.14版本的优化:编译阶段把defer代码块插入尾部(openCodedDefer 类似内联),但不适合循环中defer,循环中defer还是会插入g._defer链表
golang panic/recover原理:g._defer链表、g._panic链表(https://draveness.me/golang/docs/part2-foundation/ch05-keywor...)
编译器会做转换关键字的工作,panic和recover转换成runtime.gopanic和runtime.gorecover;defer转换成runtime.deferproc函数;
panic时,执行gopanic函数,创建新runtime._panic添加到所在G的g._panic链表头部,然后遍历g._defer链调用延迟函数,最后调用 fatalpanic 执行g._panic中止整个程序;defer与panic:先处理完g._defer链上的任务,完毕后再执行g._panic再退出整个程序,停掉执行中的程序。
panic只会触发当前G的defer,recover只有g._panic链有值时执行才会生效,即recover写在最开始的defer语句中;panic允许在defer中嵌套多次调用;
panic与recover:panic时调用g._defer链,若遇到了gorecover会将_panic.recovered标记成true并返回panic的参数,会跳到deferreturn函数继续遍历g._defer链,不再执行fatalpanic中止程序,就从panic中恢复并执行正常的逻辑;若没遇到gorecover会遍历g._defer链,最后调fatalpanic中止程序。
golang Channel结构:
lock(runtime.mutex 线程锁)、buff、qcount(缓冲区现有多少元素)、buffSize(缓冲区总大小)、closed(关闭状态)、sendx/recvx(队列下标用于数组循环队列)、sendq(等待写消息队列)、recvq(等待读消息队列)、elemtype(元素的类型)
写:如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;
读:如果缓冲区中没有数据,将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;
Channel里是有runtime.mutex线程锁的,读写时会lock一下,因为读写存在缓冲区的设计,并没有直接使用sync.Mutex,而是在mutex的基础上,增加waitq、writeq自己实现了一套排队逻辑,保障多线程并发。
golang Map结构:hashmap,是使用数组+bmap链表实现的,用拉链法消除hash冲突
key经过hash后得到一个数,其低8位用于选择bucket桶,高8位用于放在bmap的[8]uint8数组中,用于快速碰撞试错。然后一个bmap指向下一个bmap即溢出桶(拉链)。
map并发问题:入源代码写冲突 h.flags ^= hashWriting。hash冲突后元素是优先放头部,新写数据被马上访问的概率越大
扩容:扩容桶会翻倍(B+1),渐进式扩容,当访问到具体的某个bucket的时候,会把旧bucket中的数据转移到新的bucket中。一个bucket一个bucket的进行迁移,可以防止被锁住长时间不可访问(性能抖动)。扩容因子: hmap.count>桶数量 (2^B)*6.5(不包括溢出桶),无法缩容。等量扩容(伪缩容):为了减少溢出桶使用(删数据),排列的更加紧凑。非溢出桶无法自动缩容,需手动处理缩容:创建一个新的 map 并从旧的 map 中复制元素。
Sync.map结构:内部有两个map,一个read、一个dirty,读时先无锁读read,当read里没有时上锁读dirty
Sync.Pool原理:每个P创建一个本地对象池poolLocal,尽量减少并发冲突;每个poolLocal都有一个private对象,优先存取private对象,避免复杂逻辑;在Get和Put期间,利用pin锁定当前P,防止G被抢占,造成程序混乱。获取对象期间,利用对象窃取机制,从其他P的本地对象池以及victim中获取对象。充分利用CPU Cache特性,提升程序性能。
golang Select原理:https://www.codenong.com/cs106626574/
在一个select中,所有case语句会构成一个scase结构体的数组,Select和rselect都是做了简单的初始化参数 selectgo才是核心。
selectgo的做的事情是:
1、打乱传入的case结构体数组顺序(select的随机读写channel的实现方式),然后锁住其中的所有的channel。
2、遍历所有的channel,查看其是否可读或者可写。
3、其中的channel可读或者可写,则解锁所有channel,返回对应的channel数据,都没读写但有default语句,返回default语句对应的scase并解锁所有的channel。
4、没有channel可读写,没有default语句,将当前运行的groutine阻塞,加入到当前所有channel的等待队列中去,然后解锁所有channel,等待被唤醒。
5、有阻塞的channel读写ready了则唤醒,再次加锁所有channel,遍历所有channel找到对应的channel和G,唤醒G,将G从其他channel的等待队列中移除。
golang协程与epoll: https://strikefreedom.top/go-netpoll-io-multiplexing-reactor
1、当调用net.Listen时会创建listenfd并设置成非阻塞 然后调用epollCreate初始化epollfd并把 listenfd 并封装成pollDesc并调用epollCtl注册进epoll里。
2、当Accept产生connfd时,方法内部会把connfd封装成pollDesc并调用epollCtl注册进epoll里。
3、当Read或Write时,因为是非阻塞IO,当没有数据,没有IO事件发生时,会返回EAGAIN错误,会调用 fd.pd.waitRead方法,把当前G进行goPark,挂在pollDesc结构体里的rg或wg字段上,等待epollWait调度。
4、当scheule()或sysmon进行调度时,会调用netpoll方法进行epollWait调用,获取rdlist里有IO数据的fd所对应的pollDesc对象,把fd里的数据写入pollDesc里rg或wg挂载的G,然后返回G链表并注入进全局队列等待被P调度。
golang 类型元数据、接口与反射 :
类型元数据:有内置类型元数据和自定义类型元数据,自定义类型由基础类型元数据和uncommontype一起描述。type _type struct
空接口:底层结构体为eface, 结构体内容为 type *_type和 data unsafe.Pointer
非空接口:底层结构做为iface, 结构体内容为 tab *itab 和 data unsafe.Pointer
反射TypeOf与ValueOf性能差异的原因:TypeOf操作全程是在栈上进行的。ValueOf变量:会把变量显示地拷贝一份并逃逸到堆上留下拷贝变量的指针、ValueOf变量指针:会把原变量逃逸到堆上并留下原变量的指针。ValueOf增加了堆分配开销和GC开销。ValueOf里有rType指针,可以操作Type接口方法。
golang内存管理: https://www.php.cn/be/go/436885.html https://studygolang.com/articles/21025
struct{}不占用内存,编译器在内存分配时做的优化,当mallocgc发现对象size为0时,直接返回变量 zerobase 的引用,其是所有0字节的基准地址,不占据任何宽度。
Golang内存管理(mcache(管理P本地的mspan数组)、mcentral(管理全局mspan)、每个arena64M(8192个page)、mheap(单位page 8KB 共512G),mheap拆分成多个arena(64M),arena又拆分成多个span,heapArena有个spans数组对应不同的mspan,每个P都有独立的mcache减少锁开销,mspan里面按照8*2ⁿ大小的object(8b,16b,32b ....)分为67种内存块,映射到对应类型的span。
有多少种类型的mspan就有多少个mcentral。每个mcentral都会包含两个mspan的列表:没有空闲对象或mspan已经被mcache缓存的mspan列表(noempty mspanList),有空闲对象的mspan列表(empty mspanList);由于mspan是全局的,会被所有的mcache访问,所以会出现并发性问题,因而mcentral会存在一个锁。mheap有一个mcentral数组管理136个不同类型mspan的mcentral数组。
mheap管理整个512G的堆内存,heapArena管理一个64M的arena,heapArena有一个spans数组字段,用于询址,存放span元数据,mspan管理一个span(一组pages)。bitmap区域用于表示arena区域中哪些地址保存了对象, 并且对象中哪些地址包含了指针。bitmap区域中一个字节对应了arena区域中的四个指针大小的内存, 即2bit对应一个指针大小的内存,标识需不需要在扫描,就是垃圾回收用的。
Go没法使用P的本地缓存mcache和全局中心缓存mcentral上超过32KB的内存分配,大于32KB内存直接申请mheap,mcentral内存不够时向mheap申请若干page并把page切割成mspan单位大小。对于小内存对象,节省内存开销,消除内存碎片。
mspan:spanclass 0号标记为大于32K的内存,1-67号为8B-32KB的内存。spanclass最低位来标识是否要GC扫描:包含指针要GC的扫描的归为scannable这一类(0),不含指针的归为noscan这一类(1),一共分成136类。noscan是不包含指针的对象,没有子对象,GC完成root标记后不需要再对指针进行扫描。scan则代表含有指针,标记完root后需要遍历其指针扫描。把span分为scan和noscan的意义在于,GC扫描对象的时候对于noscan的span可以不去查看bitmap区域来标记子对象, 大幅提升标记的效率。
mspan里allocBits位图字段:用于标记mspan里的哪些内存块被分配了。gcmarkBits字段是用于GC标记阶段会对这个位图进行标记,看span里的内存块是否释放。到GC清扫阶段释放掉旧的allocBits,把标记的gcmarkBits用作allocBits ,然后给个清0的给gcmarkBits,这个操作就是内存清理。freeIndex记录着下个空闲的内存索引块。
零GC缓存原理,在GC时scan区标记完root后需要遍历其指针扫描,noscan区完成root标记后不需要再被GC扫描标记,从而提高GC扫描性能。
golang内存分配:
mallocgc流程:1、辅助GC;2、空间分配(三种内存分配策略:tiny对象(小于16B且无指针)、mcache.alloc、large span);3、位图标记:用于线性地址找到对应的span内存块;4、收尾(GC时新对象要标记,以及是否达到阀值要触发新一轮GC)
GC:主体并发增量式回收 https://golang.design/under-the-hood/zh-cn/part2runtime/ch08g...
Go语言引入并发垃圾收集器的同时使用垃圾收集调步(Pacing)算法计算触发的垃圾收集的最佳时间,确保触发时间不会浪费计算资源,也不会超出预期的堆大小。标记清扫算法实现起来相对简单,但容易造成碎片化,则有了mcache这种多级缓存模式。
debug.SetGCPercent:用于设置一个垃圾收集比率 设置GOGC值,默认是100。新分配内存(本次堆内存的增量)和前次垃圾收集剩下的存活数据的比率达到该百分比时,就会触发垃圾收集。默认GOGC=100,增长率100%。比如1M到2M到后面500M要到1G才会触发,当存活对象越多时越难达到阈值,所以要2分钟定时GC。
go的GC策略,每次回收都会造成CPU的抖动,因GC使用cpu时间约为总时间的25%,我们设置GOPROC为4,则每次标记时会占用1个CPU的100%时间,直到GC处理完成。GC时会有两种后台任务, 在GC初始化的准备阶段会为每个P创建了markWorker, 清扫工作只有一个后台sweeper增量进行.GCMark阶段后台任务会在需要时启动, 同时工作的markWorker数量大约是GOMAXPROCS的25%. 后台扫描标记内存对象时CPU默认利用率是25%. 调度时启动markWorker数量为P*25%。
发现内存增长特别快,标记过程太长了,对象增长太快。会限定你G的并行度,标准是25%,现在增长到了50%。这样程序会跑的慢吞吐下降了。会多了个MA(MarkAssistant)协程:标记协助者,协助GC一块进行加速标记。
若3核或者6核无法被4整除,这时需要1个G或额外1个G的部分CPU协助处理垃圾收集,运行时需要占用某个CPU的部分时间,每完成一定量工作时会计算是否达到fractionalUtilizationGoal,如果达到了FractionalWoker就可以让出cpu给其他协程用。使用Fractional模式的协程协助保证CPU利用率达到25%。
GC触发时机:手动调用,runtime.GC 、sysmon,监控线程2分钟调用、runtime.mallocgc(),申请内存时判断堆大小是否达到阈值(新分配内存比上次GC存活数据达到GOGC比率)
优化GC频率:优化内存申请速度,尽可能少申请内存,复用已申请的内存。控制(协程的数量)、减少(减少内存申请次数,减少slice map 动态扩容)、复用(sync.Pool缓存复用临时对象)、内存对齐(按占字节最大的类型来做对齐边界,压缩内存占用空间),尽可能地控制堆内存增长率从而减少GC启动。 或调整GOGC参数的值,增大触发阈值。
GC流程:写屏障只有在标记阶段启用 https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/
GC标记时会stopTheWorld启动混合写屏障(栈空间不启动,堆空间启动),然后startTheWord。再然后`标记工作`要从数据段和协程栈的root节点开始加入队列然后追踪到堆上的节点.使其变黑并一直保持,过程不需要STW. 完成标记后会stop the world关闭混合写屏障。GC标记阶段堆栈上新创建的对象都要标记成黑色。
强三色不变性:黑色对象引用的必须是灰色,弱三色不变性:所有被黑色对象引用的白色对象都有被灰色引用或间接引用.
GCMark标记准备阶段,为并发标记做准备工作,启动混合写屏障 赋值器处于 STW状态
GCMarK扫描标记阶段,与赋值器并发执行,赋值器处于 并发状态 占用GOMAXPROCS的25%的gcBgMarkWorker
GCMarkTermination标记终止阶段,保证一个周期内标记任务完成,关闭混合写屏障 赋值器这时处于 STW状态
GCoff内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 赋值器处于并发状态,GC没有运行sweep在后台运行,写屏障没有启用
GCoff内存归还阶段,将过多的内存归还给操作系统,写屏障关闭 赋值器处于并发状态,GC没有运行sweep在后台运行,写屏障没有启用
内存逃逸(逃逸分析):https://segmentfault.com/a/1190000040450335
golang逃逸分析指令:go build -gcflags '-m'
栈对象逃逸到堆上,少逃逸是为了减少对象在堆上分配,降低堆内存增长率,减少GC,从而减少没必要的GC占用CPU利用率。
外部引用(指针逃逸)、动态类型逃逸(了解interface{}的内部结构后,可以归并到指针逃逸)、栈空间不足逃逸(对象太大)、闭包引用对象逃逸、slice扩容逃逸,初始化时是分配在栈上的,运行时扩容会逃到堆上(无法确定栈空间而逃逸)。
golang协程栈空间:用的是连续栈
每个G维护着自己的栈区,它只能自己用不能被其他G用。G运行时栈区会按需增长和收缩,初始值是2KB(线程2M),不够时是成倍增长,栈空间最大限制在64位系统上是1GB,32位系统是250MB 。协程栈空间也是从堆内存里分配的,有全局缓存和本地缓存。
栈增长:连续栈其核心原理是每当程序的栈空间不足要栈增长时,开辟一片更大的栈空间(newstack)并将原栈中的所有值都迁移copystack到新栈中,旧栈则stackfree回收掉,新的局部变量或者函数调用就有充足的内存空间,栈增长是成倍扩容的。
栈收缩:运行时栈内存使用不足1/4时会缩容(shrinkstack),会调用开辟新的栈空间并runtime.copystack缩容,大小是原始栈的一半,若新栈大小低于G最低限制2KB,缩容过程就会停止,GC时发起栈收缩。
新栈的初始化和数据复制是一个简单的过程,但这不是整个过程中最复杂的地方,还要将指向源栈中的内存指向新栈。所有指针都被调整后,通过runtime.stackfree释放旧栈的内存空间。
golang 协程切换的时机:
1、select阻塞时,2、io(文件,网络)读写阻塞时,3、channel阻塞时,4、加锁/等待锁,5、sleep时,6 、GC时:GC的gcBgMarkWoker会抢占P,7 、系统调用和抢占时 sysmon线程会监控并把他们调度走,8、runtime.Gosched()。
这些阻塞会调用gopark把协程切换到对应的结构体里挂起,当就绪时goready会把他们扔回P的本地队列等待调度。系统调用时间过长时会切出去独立线程处理。
golang cpu利用率:死循环或进程有大量计算、协程、GC在执行垃圾回收时会占用所有cpu的25%。负载高:还是系统调用导致线程数多,任务数过多。
P的最大上限:Go1.10开始,P没有限制,运行时不限制GOMAXPROCS,之前限制为1024现在是int32的最大值,但也受内存限制。切片allp保存全部p。
panic触发场景:数组/切片越界、空指针异常、类型断言不匹配、除0、向已关闭channel发消息、重复关闭channel、关闭未初始化的channel、访问未初始化map、sync计数负数、过早关闭HTTP响应体
本文由mdnice多平台发布
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。