reactor 详解

在类似网关这种海量连接, 很高的并发的场景, 比如有 10W+ 连接, go 开始变得吃力. 因为频繁的 goroutine 调度和 gc 导致程序性能很差. 这个时候我们可以考虑用经典的 reactor 网络模型来应对这种需求

常见网络模型

下面是目前常见的网络模型

go 原生网络模型

go 通过 IO 多路复用构建了一套简洁而高性能原生网络模型, 让开发者可以使用同步的模式编写异步的逻辑(给每个连接开一个协程处理), 极大降低开发的难度

goroutine 从 fd Read() 数据但是又没有数据的时候, 会将当前的 goroutine 给 pack 住, 直到这个 fd 发生读事件这个 goroutine 才会重新 ready 激活(再次发生读事件就是 epoll 之类 IO 多路复用触发的)

贴一个 给每个连接开一个协程处理 的 demo 代码

  1. package main
  2. import "net"
  3. func main() {
  4. l, _ := net.Listen("tcp", ":5000")
  5. for {
  6. // 新的连接
  7. conn, _ := l.Accept()
  8. // 给每个连接开启一个协程处理
  9. go handler(conn)
  10. }
  11. }
  12. func handler(conn net.Conn) {
  13. for {
  14. // 读取数据
  15. // 业务处理
  16. // 写入返回数据
  17. }
  18. }

但是在类似网关这种海量连接, 很高的并发的场景, 比如有 10W+ 连接, go 开始变得吃力. 因为频繁的 goroutine 调度和 gc 导致程序性能很差. 这个时候我们可以考虑用经典的 reactor 来应对这种需求

go 原生网络模型不是讨论的重点, 学习请参考 reference

Proactor

第五个 Proactor 是从底层支持的真正的异步 IO, 底层在 IO 完成后会通知应用层

其他都是同步 IO, 因为没有像同步 IO 一样成熟和广泛使用, 所以不常用

Reactor 网络模型

单 Reactor 单线程

  • Reactor 通过 IO多路复用 监听 IO 事件, 然后将 IO 事件分配给 Acceptor 或者对应的 Handler 处理

  • 如果是建立连接事件: 交给 Acceptor 处理, Acceptor 会通过 accept 方法获得一个连接, 然后再创建一个 handler 来处理对应的响应事件

  • 如果不是建立连接事件: 交个当前连接对应的 Handler 对象进行响应

  • Handler 对象需要从连接 read 数据 -> 业务处理 -> 将返回数据 write 到连接中

Redis 单 Reactor 单线程模型

  • 使用 IO多路复用 循环获取事件并进行事件分发
  • 事件分发器会通过回调函数 串行 执行所有的事件(accept 连接创建, read, 业务处理, write)

单 Reactor 多线程

  • Reactor 通过 IO多路复用 监听 IO 事件, 然后将 IO 事件分配给 Acceptor 或者对应的 Handler 处理

  • 如果不是建立连接事件: 交给 Acceptor 处理, Acceptor 会通过 accept 方法获得一个连接, 然后再创建一个 handler 来处理对应的响应事件

  • 如果不是是事件, 就交个当前连接对应的 Handler 对象进行响应

前面都是一样的

  • 主线程 Handler 对象需要从连接 read 数据 , 然后给线程池 processor 执行
  • 线程池执行业务完毕后会添加写事件到 Reactor
  • Reactor 下一次事件循环的时候会处理写事件

其实上面的模型给人一种 handler 要等待 processor 执行完成再执行 write 的感觉, 其实这样和单线程没啥区别, 所以不能这样理解

正确的是 processor 执行完后将写事件添加到 Reactor 然后 Reactor 下一次循环的时候处理写事件

下面是数据流动正确的图

redis 6.0 的多线程 IO

不同的是

  • redis 6.0 : IO 是多线程, 业务处理是主线程
  • 单 Reactor 多线程网络模型: IO 是主线程, 业务处理是多线程

  • 主线程 使用 Reactor epoll_wait (IO 多路复用) 获取可读的 socket fd, 并分配给 IO 线程池读取数据, 主线程阻塞等待 IO 线程池将数据读取完毕
  • IO 线程层读取数据并解析为 redis 命令, 放到读缓冲区
  • 主线程顺序执行解析好的命令, 并将返回的结果放到写缓冲区, 阻塞等待 IO 线程数据写入完毕
  • IO 线程写入返回的数据到 fd
  • 清空缓冲区和队列, 继续事件循环

多 Reactor 多线程

因为一个 Reactor 对象承担所有事件的监听和响应, 而且只在主线程中运行, 在面对瞬间高并发的场景时, 容易成为性能的瓶颈

所以就考虑多 Reactor 来解决这个问题

  • 主线程的 Reactor 负责 accept 连接, 然后将连接交给线程池的 Reactor
  • 线程池的 Reactor 负责获取连接的读事件, 然后交给 handler 执行

reference

如何深刻理解Reactor和Proactor? - 知乎 (zhihu.com)

如何用Go实现一个异步网络库? - 知乎 (zhihu.com)

Go netpoller 网络模型之源码全面解析 - 知乎 (zhihu.com)

Redis中的Reactor模型 - 掘金 (juejin.cn)

Redis 6.0 多线程IO处理过程详解 - 知乎 (zhihu.com)

Redis 6.0 新特性:带你 100% 掌握多线程模型 - SegmentFault 思否

Millions of websocket and go

gev 源码分析

阅读这篇文章前, 请一定先了解 Reactor 网络模型

简介

gev 是一个轻量、快速的基于 Reactor 模式的非阻塞 TCP 网络库 / websocket server,支持自定义协议,轻松快速搭建高性能服务器。

特点

  • 基于 epoll 和 kqueue 实现的高性能事件循环
  • 支持多核多线程
  • 动态扩容 Ring Buffer 实现的读写缓冲区
  • 异步读写
  • 自动清理空闲连接
  • SO_REUSEPORT 端口重用支持
  • 支持 WebSocket/Protobuf, 自定义协议
  • 支持定时任务,延时任务
  • 开箱即用的高性能 websocket server

网络模型

gev 只使用极少的 goroutine, 一个 goroutine 负责监听客户端连接,其他 goroutine (work 协程)负责处理已连接客户端的读写事件,work 协程数量可以配置,默认与运行主机 CPU 数量相同。

多 Reactor 多线程

gev 是基于多 Reactor 多线程网络模型构建的, listener(MainReactor) 通过负载均衡策略将新连接分配给 worker(子线程Reactor)

gev.NewServer 初始化

  1. // NewServer 创建 Server
  2. // @param handler 用户定义的 Handler 处理逻辑
  3. // @param opts 配置
  4. func NewServer(handler Handler, opts ...Option) (server *Server, err error)

Handler 用户自定义的处理逻辑的函数

  1. // Handler Server 注册接口
  2. type Handler interface {
  3. CallBack
  4. OnConnect(c *Connection)
  5. }
  6. type CallBack interface {
  7. OnMessage(c *Connection, ctx interface{}, data []byte) interface{}
  8. OnClose(c *Connection)
  9. }

配置参数

  1. // Options 服务配置
  2. type Options struct {
  3. Network string // 目前只支持 tcp
  4. Address string // 监听地址
  5. NumLoops int // worker 的数量
  6. ReusePort bool // 是否可重用端口
  7. IdleTime time.Duration // 最大空闲时间
  8. Protocol Protocol // 自定义数据包的拆包解包 Pack, UnPack
  9. Strategy LoadBalanceStrategy // 负载均衡策略, 目前支持轮询和最小连接数
  10. tick time.Duration // 层级时间轮的 tick
  11. wheelSize int64 // 层级时间轮 size
  12. metricsPath, metricsAddress string // metric 指标暴露地址
  13. }

Server

  1. // Server gev Server
  2. type Server struct {
  3. listener *listener // 监听的主 Reactor
  4. workLoops []*eventloop.EventLoop // worker Reactor
  5. callback Handler // 用户定义的 Handler 函数
  6. timingWheel *timingwheel.TimingWheel // 类似 kafka 中的层级定时器
  7. opts *Options // Options 配置
  8. running atomic.Bool // 状态是否为正在运行
  9. }

listener

  1. // listener 监听TCP连接, 作为多Reactor多线程网络模型中的主 Reactor
  2. type listener struct {
  3. file *os.File // 监听的 socket 文件
  4. fd int // 监听的 socket 文件 fd
  5. handleC handleConnFunc // 处理新连接 Conn 的函数(默认是通过负载均衡交给 worker)
  6. listener net.Listener // Listener 对象
  7. loop *eventloop.EventLoop // 事件循环
  8. }

eventloop: listener 和 worker 都是通过 eventloop 来实现的

  1. type eventLoopLocal struct {
  2. ConnCunt atomic.Int64 // 连接数
  3. needWake *atomic.Bool // 是否需要唤醒
  4. poll *poller.Poller // poller linux 下由 epoll 实现
  5. mu spinlock.SpinLock // 自旋锁
  6. sockets map[int]Socket // fd -> Socket
  7. packet []byte // 内部使用,临时缓冲区
  8. taskQueueW []func() // 写事件队列
  9. taskQueueR []func() // 读事件队列
  10. UserBuffer *[]byte // 用户缓冲区
  11. }

poller.Poller 在 linux 下由 epoll 实现, eventloop 通过 poller.Poller 来实现事件循环

  1. type Poller struct {
  2. fd int
  3. eventFd int
  4. buf []byte
  5. running atomic.Bool
  6. waitDone chan struct{}
  7. }

总结:

  • NewServer 初始化 Server, 会初始化一个 listener (主 Reactor), 一个或多个 worker Reactor, 一个层级时间轮, 一个用户自定义的 Handler
  • listener, 和 worker 通过 poller.Poller 来实现事件循环, listener 通过负载均衡策略将新连接分配给 worker (Reactor 网络模型是: 一个主 Reactor, 多个 worker Reactor)

s.Start() 运行

  1. // Start 启动 Server
  2. // 在协程中启动主 Reactor 和 worker Reactor, 开始事件循环
  3. func (s *Server) Start() {
  4. sw := sync.WaitGroupWrapper{}
  5. // 启动定时器
  6. s.timingWheel.Start()
  7. // 启动 worker
  8. length := len(s.workLoops)
  9. for i := 0; i < length; i++ {
  10. sw.AddAndRun(s.workLoops[i].Run)
  11. }
  12. // 启动主 Reactor
  13. sw.AddAndRun(s.listener.Run)
  14. s.running.Set(true)
  15. sw.Wait()
  16. }

listener.Run 和 worker.Run 都是通过 eventloop.Run 来实现的, 传入的回调函数不同

  1. // Run 启动事件循环
  2. func (l *EventLoop) Run() {
  3. l.poll.Poll(l.handlerEvent)
  4. }

eventloop.Run 是由 poll 实现的

  1. // Poll 启动 epoll wait 循环
  2. func (ep *Poller) Poll(handler func(fd int, event Event)) {
  3. defer func() {
  4. close(ep.waitDone)
  5. }()
  6. events := make([]unix.EpollEvent, waitEventsBegin)
  7. var (
  8. wake bool
  9. msec int
  10. )
  11. ep.running.Set(true)
  12. for {
  13. // wait 等待事件并读取
  14. n, err := unix.EpollWait(ep.fd, events, msec)
  15. if err != nil && err != unix.EINTR {
  16. log.Error("EpollWait: ", err)
  17. continue
  18. }
  19. if n <= 0 {
  20. msec = -1
  21. runtime.Gosched()
  22. continue
  23. }
  24. msec = 0
  25. // 遍历读取的事件
  26. for i := 0; i < n; i++ {
  27. fd := int(events[i].Fd)
  28. if fd != ep.eventFd {
  29. var rEvents Event
  30. if ((events[i].Events & unix.POLLHUP) != 0) && ((events[i].Events & unix.POLLIN) == 0) {
  31. rEvents |= EventErr
  32. }
  33. if (events[i].Events&unix.EPOLLERR != 0) || (events[i].Events&unix.EPOLLOUT != 0) {
  34. rEvents |= EventWrite
  35. }
  36. if events[i].Events&(unix.EPOLLIN|unix.EPOLLPRI|unix.EPOLLRDHUP) != 0 {
  37. rEvents |= EventRead
  38. }
  39. // 回调函数处理事件
  40. handler(fd, rEvents)
  41. } else {
  42. ep.wakeHandlerRead()
  43. wake = true
  44. }
  45. }
  46. if wake {
  47. handler(-1, 0)
  48. wake = false
  49. if !ep.running.Get() {
  50. return
  51. }
  52. }
  53. if n == len(events) {
  54. events = make([]unix.EpollEvent, n*2)
  55. }
  56. }
  57. }

poll 会从 EpollWait 中等待读/写事件, 并交给回调函数处理

我们看看 listener.Run 的回调函数 handleNewConnection

  1. func (s *Server) handleNewConnection(fd int, sa unix.Sockaddr) {
  2. // 通过负载均衡找到一个 worker
  3. loop := s.opts.Strategy(s.workLoops)
  4. // 创建连接
  5. c := NewConnection(fd, loop, sa, s.opts.Protocol, s.timingWheel, s.opts.IdleTime, s.callback)
  6. // 添加一个事件, 将连接放到 worker 里面
  7. loop.QueueInLoop(func() {
  8. s.callback.OnConnect(c)
  9. if err := loop.AddSocketAndEnableRead(fd, c); err != nil {
  10. log.Error("[AddSocketAndEnableRead]", err)
  11. }
  12. })
  13. }

我们看看 workLoops[i].Run 的回调函数 handleLoop

  1. // workLoops[i].Run 的回调函数
  2. func (l *EventLoop) handlerEvent(fd int, events poller.Event) {
  3. if fd != -1 {
  4. // 找到 fd 对应的 socket 调用 socket.HandleEvent
  5. s, ok := l.sockets[fd]
  6. if ok {
  7. s.HandleEvent(fd, events)
  8. }
  9. } else {
  10. l.needWake.Set(true)
  11. l.doPendingFunc()
  12. }
  13. }

EventLoop.handleLoop 回调的是 Socket.HandleEvent

Socket 是一个接口, Connection 是 Socket 的实现类

我们看看 Collection.HandleEvent

  1. // HandleEvent 内部使用,event loop 回调
  2. func (c *Connection) HandleEvent(fd int, events poller.Event) {
  3. // ...
  4. // 处理错误事件, 关闭连接
  5. if events&poller.EventErr != 0 {
  6. c.handleClose(fd)
  7. return
  8. }
  9. // 处理写事件
  10. if !c.outBuffer.IsEmpty() {
  11. if events&poller.EventWrite != 0 {
  12. // if return true, it means closed
  13. if c.handleWrite(fd) {
  14. return
  15. }
  16. // ...
  17. }
  18. } else if events&poller.EventRead != 0 {
  19. // 处理读事件
  20. if c.handleRead(fd) {
  21. return
  22. }
  23. // ...
  24. }
  25. // ...
  26. }

我们看看处理读事件 c.handleRead(fd)

  1. // 处理读事件
  2. func (c *Connection) handleRead(fd int) (closed bool) {
  3. // TODO 避免这次内存拷贝
  4. // 将数据读取到 buf
  5. buf := c.loop.PacketBuf()
  6. n, err := unix.Read(c.fd, buf)
  7. // ...
  8. // read buffer 读完了
  9. if c.inBuffer.IsEmpty() {
  10. // 将 buf 中的数据写入 buffer
  11. c.buffer.WithData(buf[:n])
  12. buf = buf[n:n]
  13. // 协议解码 UnPacket -> 协议处理 OnMessage -> 返回的数据编码 Packet
  14. c.handlerProtocol(&buf, c.buffer)
  15. if !c.buffer.IsEmpty() {
  16. first, _ := c.buffer.PeekAll()
  17. _, _ = c.inBuffer.Write(first)
  18. }
  19. } else { // read buffer 还有数据
  20. // 将 buf 中的数据写入 read buffer
  21. _, _ = c.inBuffer.Write(buf[:n])
  22. buf = buf[:0]
  23. // 协议解码 UnPacket -> 协议处理 OnMessage -> 返回的数据编码 Packet
  24. c.handlerProtocol(&buf, c.inBuffer)
  25. }
  26. // 将返回的数据发送写给客户端 fd
  27. if len(buf) != 0 {
  28. closed = c.sendInLoop(buf)
  29. }
  30. return
  31. }

我们看看 handlerProtocol 怎么做的

  1. // 协议解码 UnPacket -> 协议处理 OnMessage -> 返回的数据编码 Packet
  2. // @param tmpBuffer 临时 buffer, 用于存储返回的数据
  3. // @param buffer 已经读取好的 buffer
  4. func (c *Connection) handlerProtocol(tmpBuffer *[]byte, buffer *ringbuffer.RingBuffer) {
  5. ctx, receivedData := c.protocol.UnPacket(c, buffer)
  6. for ctx != nil || len(receivedData) != 0 {
  7. sendData := c.callBack.OnMessage(c, ctx, receivedData)
  8. if sendData != nil {
  9. *tmpBuffer = append(*tmpBuffer, c.protocol.Packet(c, sendData)...)
  10. }
  11. // 如果有多个数据包,继续解析处理
  12. ctx, receivedData = c.protocol.UnPacket(c, buffer)
  13. }
  14. }

OnMessage 就是我们为了实现 Handler 接口定义的回调函数, 例如

  1. type example struct {
  2. Count atomic.Int64
  3. }
  4. func (s *example) OnConnect(c *gev.Connection) {
  5. s.Count.Add(1)
  6. //log.Println(" OnConnect : ", c.PeerAddr())
  7. }
  8. func (s *example) OnMessage(c *gev.Connection, ctx interface{}, data []byte) (out interface{}) {
  9. //log.Println("OnMessage")
  10. out = data
  11. return
  12. }
  13. func (s *example) OnClose(c *gev.Connection) {
  14. s.Count.Add(-1)
  15. //log.Println("OnClose")
  16. }

我们看看 OnMessage 处理完后返回的数据怎么返回给客户端的 c.sendInLoop(buf)

buf 是 OnMessage 处理完后返回的数据, 并且 Packet 之后的数据

  1. // 将返回的数据放到 outBuffer 中
  2. func (c *Connection) sendInLoop(data []byte) (closed bool) {
  3. // 如果 outBuffer 为空, 直接将数据放到 outBuffer 中
  4. if !c.outBuffer.IsEmpty() {
  5. _, _ = c.outBuffer.Write(data)
  6. } else {
  7. // 如果 outBuffer 不为空, 尝试写入数据
  8. n, err := unix.Write(c.fd, data)
  9. if err != nil && err != unix.EAGAIN {
  10. c.handleClose(c.fd)
  11. closed = true
  12. return
  13. }
  14. // 把没写完的数据放到 outBuffer 中
  15. if n <= 0 {
  16. _, _ = c.outBuffer.Write(data)
  17. } else if n < len(data) {
  18. _, _ = c.outBuffer.Write(data[n:])
  19. }
  20. // 如果 outBuffer 不为空, 则注册写事件
  21. if !c.outBuffer.IsEmpty() {
  22. _ = c.loop.EnableReadWrite(c.fd)
  23. }
  24. }
  25. return
  26. }

gev 处理请求后, 会将返回的数据储存到 outBuffer 里面 (如果 outBuffer 为空会先尝试直接将数据发送到网卡), 然后注册写事件到 poller (linux 使用 epoll 实现), 循环 epoll 事件的时候, 如果有写事件, 就会将 outBuffer 中的数据发送到网卡

快速开始

参考 GitHub 库中的快速开始和 example, 文档里面已经写的很好了, 这里就不再赘述了

贴一个 echo 的快速开始

  1. package main
  2. import (
  3. "flag"
  4. "net/http"
  5. _ "net/http/pprof"
  6. "strconv"
  7. "time"
  8. "github.com/Allenxuxu/gev"
  9. "github.com/Allenxuxu/gev/log"
  10. "github.com/Allenxuxu/toolkit/sync/atomic"
  11. )
  12. type example struct {
  13. Count atomic.Int64
  14. }
  15. func (s *example) OnConnect(c *gev.Connection) {
  16. s.Count.Add(1)
  17. //log.Println(" OnConnect : ", c.PeerAddr())
  18. }
  19. func (s *example) OnMessage(c *gev.Connection, ctx interface{}, data []byte) (out interface{}) {
  20. //log.Println("OnMessage")
  21. out = data
  22. return
  23. }
  24. func (s *example) OnClose(c *gev.Connection) {
  25. s.Count.Add(-1)
  26. //log.Println("OnClose")
  27. }
  28. func main() {
  29. go func() {
  30. if err := http.ListenAndServe(":6060", nil); err != nil {
  31. panic(err)
  32. }
  33. }()
  34. handler := new(example)
  35. var port int
  36. var loops int
  37. flag.IntVar(&port, "port", 1833, "server port")
  38. flag.IntVar(&loops, "loops", -1, "num loops")
  39. flag.Parse()
  40. s, err := gev.NewServer(handler,
  41. gev.Network("tcp"),
  42. gev.Address(":"+strconv.Itoa(port)),
  43. gev.NumLoops(loops),
  44. gev.MetricsServer("", ":9091"),
  45. )
  46. if err != nil {
  47. panic(err)
  48. }
  49. s.RunEvery(time.Second*2, func() {
  50. log.Info("connections :", handler.Count.Get())
  51. })
  52. s.Start()
  53. }

reference

https://github.com/Allenxuxu/gev

Reactor And Gev 详解 通俗易懂的更多相关文章

  1. Netty源码分析之Reactor线程模型详解

    上一篇文章,分析了Netty服务端启动的初始化过程,今天我们来分析一下Netty中的Reactor线程模型 在分析源码之前,我们先分析,哪些地方用到了EventLoop? NioServerSocke ...

  2. Java--集合框架详解

    前言 Java集合框架的知识在Java基础阶段是极其重要的,我平时使用List.Set和Map集合时经常出错,常用方法还记不牢, 于是就编写这篇博客来完整的学习一下Java集合框架的知识,如有遗漏和错 ...

  3. Reactor详解之:异常处理

    目录 简介 Reactor的异常一般处理方法 各种异常处理方式详解 Static Fallback Value Fallback Method Dynamic Fallback Value Catch ...

  4. EasyPR--开发详解(6)SVM开发详解

    在前面的几篇文章中,我们介绍了EasyPR中车牌定位模块的相关内容.本文开始分析车牌定位模块后续步骤的车牌判断模块.车牌判断模块是EasyPR中的基于机器学习模型的一个模块,这个模型就是作者前文中从机 ...

  5. Windows WMIC命令使用详解

    本文转载出处http://www.jb51.net/article/49987.htm www.makaidong.com/博客园文/32743.shtml wmic alias list brief ...

  6. 理论经典:TCP协议的3次握手与4次挥手过程详解

    1.前言 尽管TCP和UDP都使用相同的网络层(IP),TCP却向应用层提供与UDP完全不同的服务.TCP提供一种面向连接的.可靠的字节流服务. 面向连接意味着两个使用TCP的应用(通常是一个客户和一 ...

  7. Protocol Buffer技术详解(Java实例)

    Protocol Buffer技术详解(Java实例) 该篇Blog和上一篇(C++实例)基本相同,只是面向于我们团队中的Java工程师,毕竟我们项目的前端部分是基于Android开发的,而且我们研发 ...

  8. C++构造函数详解及显式调用构造函数

    来源:http://www.cnblogs.com/xkfz007/archive/2012/05/11/2496447.html       c++类的构造函数详解                  ...

  9. C++中构造函数详解及显式调用构造函数

    C++构造函数详解及显式调用构造函数                                         c++类的构造函数详解                        一. 构造函 ...

随机推荐

  1. go-zero微服务实战系列(十一、大结局)

    本篇是整个系列的最后一篇了,本来打算在系列的最后一两篇写一下关于k8s部署相关的内容,在构思的过程中觉得自己对k8s知识的掌握还很不足,在自己没有理解掌握的前提下我觉得也很难写出自己满意的文章,大家看 ...

  2. vite搭建一个vue2的框架

    01-创建一个基础的模板框架 npm init vite@latest  02-安装依赖 npm install npm install vue@2.x vue-template-compiler@2 ...

  3. jdbc 06: 实现登陆页面

    jdbc连接mysql,实现简单的登陆验证 package com.examples.jdbc.o6_实现登录界面; import java.sql.*; import java.util.HashM ...

  4. C#基础语法之-泛型

    泛型:一共7个知识点 1.引入泛型,延迟声明 2.如何声明和使用泛型 3.泛型的好处和原理 4.泛型类,泛型方法,泛型接口,泛型委托 5.泛型约束 6.协变,逆变 7.泛型缓存 一.为啥会出现泛型,有 ...

  5. python3学习笔记之字符串

    字符串 1.一个个字符组成的有序的序列,是字符的集合: 2.使用单引号.双引号.三引号引住的字符序列 3.字符串是不可变对象 4.python3起,字符串就是Unicode类型: 字符串特殊举例: 不 ...

  6. YII事件EVENT示例

    模型中/** * 在初始化时进行事件绑定 */ public function init() { $this->on(self::EVENT_HELLO,[$this,'sendMail']); ...

  7. Docker容器保姆:在centos7.6上利用docker-compose统一管理容器和服务

    原文转载自「刘悦的技术博客」https://v3u.cn/a_id_108 众所周知,一个大型的Docker容器组成的微服务应用中,容器的数量是非常巨大的,如果依赖传统的人工配置方式进行维护,对于开发 ...

  8. 图片系列(6)不同版本上 Bitmap 内存分配与回收原理对比

    请点赞关注,你的支持对我意义重大. Hi,我是小彭.本文已收录到 GitHub · AndroidFamily 中.这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] ...

  9. mustache.js常见用法

    一.mustache基于JS模板引擎,能较为快捷和简单得实现数据得渲染 用法: 1.CDN引入mustache.js,以下是4.0.1版本,有需要可以去github上查询其他版本的代码. (funct ...

  10. [HNOI2010]弹飞绵羊 (平衡树,LCT动态树)

    题面 题解 因为每个点都只能向后跳到一个唯一的点,但可能不止一个点能跳到后面的某个相同的点, 所以我们把它抽象成一个森林.(思考:为什么是森林而不是树?) 子节点可以跳到父节点,根节点再跳就跳飞了. ...