golang gui编程_从Go编程中学到的教训

golang gui编程

当您使用复杂的分布式系统时,可能会遇到并发处理的需求。 在Mode.net ,我们每天处理实时,快速和灵活的软件。 没有高度并发的系统,就不可能建立一个以毫秒为单位动态路由数据包的全球专用网络。 这种动态路由是基于网络状态的,尽管这里有许多参数要考虑,但我们的重点是链路指标 。 在我们的上下文中,链接指标可以是与网络链接的状态或当前属性(例如,链接延迟)有关的任何内容。

并发探测链接指标

HALO (逐跳自适应链路状态最优路由)是我们的动态路由算法,部分依靠链路度量来计算其路由表。 这些指标由位于每个PoP (存在点)上的独立组件收集。 PoP是代表我们网络中单个路由实体的机器,它们通过链接连接并分布在形成我们网络的多个位置。 该组件使用网络数据包探测相邻计算机,这些邻居将反弹回初始探测。 链路等待时间值可以从接收到的探测中得出。 因为每个PoP都有一个以上的邻居,所以这种任务的本质是并发的:我们需要实时测量每个邻居链路的延迟。 我们负担不起顺序处理; 为了计算此指标,必须尽快处理每个探针。

latency computation graph

序列号和重置:重新排序情况

我们的探测组件交换数据包,并依靠序列号进行数据包处理。 这旨在避免处理分组重复或乱序分组。 我们的第一个实现依靠特殊的序列号0重置序列号。 这样的数字仅在组件初始化期间使用。 主要问题是我们正在考虑一个始终从0开始的递增序列号值。组件重新启动后,可能会发生数据包重新排序,并且数据包可以轻松地用重置之前使用的值替换序列号。 这意味着随后的数据包将被忽略,直到达到重置之前使用的序列号。

UDP握手和有限状态机

这里的问题是组件重新启动后序列号是否正确一致。 有几种方法可以解决此问题,在讨论了我们的选项之后,我们选择实现带有清晰状态定义的三向握手协议。 此握手在初始化期间通过链接建立会话。 这样可以确保节点通过同一会话进行通信并为其使用适当的序列号。

为了正确实现这一点,我们必须定义一个具有清晰状态和过渡的有限状态机。 这使我们能够适当地管理握手形成的所有极端情况。

finite state machine diagram

会话ID由握手启动器生成。 完整的交换顺序如下:

  1. 发件人发送SYN(ID)   包。
  2. 接收器存储接收到的ID并发送SYN-ACK(ID)
  3. 发送方接收SYN-ACK(ID)并发出ACK(ID)  它还开始发送从序列号0开始的数据包。
  4. 接收者检查最后收到的ID   并接受ACK(ID)   如果ID匹配。 它还开始接受序列号为0的数据包。

处理状态超时

基本上,在每种状态下,您最多都需要处理三种类型的事件:链接事件,数据包事件和超时事件。 这些事件会同时显示,因此在这里您必须正确处理并发。

  • 链接事件是链接更新或链接更新。 这可以启动链接会话或中断现有会话。
  • 数据包事件是控制数据包(SYN / SYN-ACK / ACK)或仅仅是探测响应。
  • 超时事件是针对当前会话状态的预定超时到期后触发的事件。

这里的主要挑战是如何处理并发超时到期和其他事件。 在这里,人们很容易陷入僵局和竞争状况的陷阱。

第一种方法

该项目使用的语言是Golang 。 它确实提供了本机同步机制,例如本机通道和锁,并且能够旋转轻量级线程以进行并发处理。

gophers hacking together

地鼠一起砍

首先,您可以设计一个代表我们的会话超时处理程序的结构


   
   
type Session struct {  
  State SessionState  
  Id SessionId  
  RemoteIp string  
}

type TimeoutHandler struct {  
  callback func ( Session )  
  session Session  
  duration int  
  timer * timer . Timer  
}

会话使用会话ID,相邻链路IP和当前会话状态来标识连接会话。

TimeoutHandler包含回调函数,应为其运行的会话,持续时间以及指向已调度计时器的指针。

有一个全局映射,该映射将为每个相邻的链接会话存储计划的超时处理程序。

 SessionTimeout map [ Session ] * TimeoutHandler 

通过以下方法可以注册和取消超时:


   
   
// schedules the timeout callback function.  
func ( timeout * TimeoutHandler ) Register () {  
  timeout . timer = time . AfterFunc ( time . Duration ( timeout . duration ) * time . Second , func () {  
    timeout . callback ( timeout . session )  
  })  
}

func ( timeout * TimeoutHandler ) Cancel () {  
  if timeout . timer == nil {  
    return  
  }  
  timeout . timer . Stop ()  
}

对于超时的创建和存储,可以使用如下方法:


   
   
func CreateTimeoutHandler ( callback func ( Session ), session Session , duration int ) * TimeoutHandler {  
  if sessionTimeout [ session ] == nil {  
    sessionTimeout [ session ] := new ( TimeoutHandler )  
  }  
   
  timeout = sessionTimeout [ session ]  
  timeout . session = session  
  timeout . callback = callback  
  timeout . duration = duration  
  return timeout  
}

一旦创建并注册了超时处理程序,它将在持续时间秒数之后运行回调。 但是,某些事件将要求您重新安排超时处理程序(因为它在SYN状态下发生-每3秒一次)。

为此,您可以让回调函数重新安排新的超时:


   
   
func synCallback ( session Session ) {  
  sendSynPacket ( session )

  // reschedules the same callback.  
  newTimeout := NewTimeoutHandler ( synCallback , session , SYN_TIMEOUT_DURATION )  
  newTimeout . Register ()

  sessionTimeout [ state ] = newTimeout  
}

该回调将在新的超时处理程序中重新安排自身的时间,并更新全局sessionTimeout映射。

数据竞赛和参考

您的解决方案已准备就绪。 一个简单的测试是检查计时器到期后是否执行了超时回调。 为此,请注册一个超时,在其持续时间内Hibernate,然后检查回调操作是否已完成。 执行测试后,最好取消计划的超时(因为它会重新计划),因此在两次测试之间不会产生副作用。

令人惊讶的是,这个简单的测试在解决方案中发现了一个错误。 使用cancel方法取消超时只是没有完成其工作。 以下事件顺序将导致数据争用情况:

  1. 您有一个计划的超时处理程序。
  2. 线程1:
    a)您收到一个控制数据包,现在您要取消注册的超时并进入下一个会话状态。 (例如收到SYN-ACK 发送SYN之后 )。
    b)您调用timeout.Cancel() ,它调用了timer.Stop() 。 (请注意,Golang计时器停止不会阻止已过期的计时器运行。)
  3. 线程2:
    a)在该取消调用之前,计时器已到期,并且回调即将执行。
    b)执行回调,它计划新的超时并更新全局映射。
  4. 线程1:
    a)转换到新的会话状态并注册新的超时,从而更新全局映射。

两个线程正在同时更新超时映射。 最终结果是您无法取消注册的超时,然后您也丢失了对线程2完成的重新安排的超时的引用。这导致处理程序在一段时间内继续执行和重新安排,并执行了不良行为。

当锁定还不够时

使用锁也不能完全解决问题。 如果在处理任何事件之前和执行回调之前添加锁,它仍然不会阻止过期的回调运行:


   
   
func ( timeout * TimeoutHandler ) Register () {  
  timeout . timer = time . AfterFunc ( time . Duration ( timeout . duration ) * time . _Second_ , func () {  
    stateLock . Lock ()  
    defer stateLock . Unlock ()

    timeout . callback ( timeout . session )  
  })  
}

现在的区别是全局映射中的更新是同步的,但这不会阻止回调在您调用超时后运行。Cancel() —如果计划的计时器已过期但没有抓住锁,就会发生这种情况然而。 您应该再次丢失对已注册超时之一的引用。

使用取消渠道

您可以使用取消通道,而不必依赖golang的timer.Stop() ,它不会阻止过期的计时器执行。

这是一个稍微不同的方法。 现在,您将不会通过回调进行递归重新计划; 相反,您注册一个无限循环,等待取消信号或超时事件。

新的Register()产生一个新的go线程,该线程在超时后运行您的回调,并在执行前一个超时后安排新的超时。 取消通道返回给调用方以控制循环应在何时停止。


   
   
func ( timeout * TimeoutHandler ) Register () chan struct {} {  
  cancelChan := make ( chan struct {})  
   
  go func () {  
    select {  
    case _ = < - cancelChan :  
      return  
    case _ = < - time . AfterFunc ( time . Duration ( timeout . duration ) * time . Second ):  
      func () {  
        stateLock . Lock ()  
        defer stateLock . Unlock ()

        timeout . callback ( timeout . session )  
      } ()  
    }  
  } ()

  return cancelChan  
}

func ( timeout * TimeoutHandler ) Cancel () {  
  if timeout . cancelChan == nil {  
    return  
  }  
  timeout . cancelChan < - struct {}{}  
}

这种方法为您注册的每个超时提供了一个取消通道。 取消调用将一个空结构发送到通道并触发取消。 但是,这不能解决先前的问题。 超时可能会在您通过通道调用Cancel之前以及超时线程获取锁之前到期。

此处的解决方案是您抓住锁之后检查超时范围内的取消通道。


   
   
  case _ = < - time . AfterFunc ( time . Duration ( timeout . duration ) * time . Second ):  
    func () {  
      stateLock . Lock ()  
      defer stateLock . Unlock ()  
     
      select {  
      case _ = < - handler . cancelChan :  
        return  
      default :  
        timeout . callback ( timeout . session )  
      }  
    } ()  
  }

最后,这保证了仅在您抓住锁之后才执行回调,并且不会触发取消。

当心死锁

此解决方案似乎有效; 但是,这里有一个隐藏的陷阱: 死锁

请再次阅读上面的代码,然后尝试自己找到它。 考虑并发调用所描述的任何方法。

这里的最后一个问题是取消通道本身。 我们将其设置为无缓冲通道,这意味着发送是阻塞呼叫。 在超时处理程序中调用“取消”后,只有在该处理程序被取消后才能继续操作。 这里的问题是,当您有多个呼叫到同一个取消通道时,取消请求仅使用一次。 如果并发事件要取消相同的超时处理程序,例如链接断开或控制数据包事件,则很容易发生这种情况。 这将导致死锁,可能会使应用程序停止。

gophers on a wire, talking

有人在听吗?

特雷弗·佛瑞(Trevor Forrey)。 经许可使用。

这里的解决方案是至少使通道缓冲一个,因此发送并不总是阻塞,并且在并发调用的情况下显式使发送不阻塞。 这样可以确保取消发送一次,并且不会阻止后续的取消调用。


   
   
func ( timeout * TimeoutHandler ) Cancel () {  
  if timeout . cancelChan == nil {  
    return  
  }  
   
  select {  
  case timeout . cancelChan < - struct {}{}:  
  default :  
    // can’t send on the channel, someone has already requested the cancellation.  
  }  
}

结论

您在实践中了解了在使用并发代码时如何出现常见错误。 由于其不确定性,即使进行大量测试,也很容易发现这些问题。 这是我们在初始实现中遇到的三个主要问题。

在不同步的情况下更新共享数据

这似乎很明显,但是如果同时进行的更新发生在不同的位置,则实际上很难发现。 结果是数据争用,由于一个更新覆盖另一个更新,对同一数据的多次更新可能导致更新丢失。 在我们的例子中,我们正在更新同一共享映射上的计划超时参考。 (有趣的是,如果Go在同一Map对象上检测到并发读/写操作,则会引发致命错误-您可以尝试运行Go的数据竞争检测器 )。 这最终会导致丢失超时参考,并且无法取消给定的超时。 始终记得在需要时使用锁。

gopher assembly line

别忘了同步地鼠的工作

缺少条件检查

在您不能仅依赖于锁独占性的情况下,需要条件检查。 我们的情况有些不同,但是核心思想与条件变量相同。 想象一下一个经典情况,您有一个生产者和多个消费者使用一个共享队列。 生产者可以将一项添加到队列中,并唤醒所有消费者。 唤醒呼叫意味着队列中有一些数据可用,并且由于队列是共享的,因此必须通过锁来同步访问。 每个消费者都有机会抓住锁; 但是,您仍然需要检查队列中是否有项目。 需要条件检查,因为在您获取锁时您不知道队列状态。

在我们的示例中,超时处理程序从计时器到期中收到了“唤醒”调用,但是在继续进行回调执行之前,它仍然需要检查是否已向其发送了取消信号。

gopher boot camp

如果您唤醒多个地鼠,可能需要进行条件检查

死锁

当一个线程被卡住,无限期地等待一个信号唤醒时,就会发生这种情况,但是这个信号永远不会到达。 通过停止整个程序执行,这些程序可以完全杀死您的应用程序。

在我们的例子中,这是由于多次发送调用到一个非缓冲且阻塞的通道而发生的。 这意味着仅在同一通道上完成接收后,发送调用才会返回。 我们的超时线程循环正在及时在取消通道上接收信号; 但是,在收到第一个信号后,它将中断环路,并且再也不会从该通道读取数据。 其余的呼叫者将永远被卡住。 为避免这种情况,您需要仔细考虑代码,谨慎处理阻塞调用,并确保不会发生线程饥饿。 在我们的示例中,解决方法是使取消调用成为非阻塞的,因为我们不需要阻塞调用。

翻译自: https://opensource.com/article/19/12/go-common-pitfalls

golang gui编程

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值