golang gui编程
当您使用复杂的分布式系统时,可能会遇到并发处理的需求。 在Mode.net ,我们每天处理实时,快速和灵活的软件。 没有高度并发的系统,就不可能建立一个以毫秒为单位动态路由数据包的全球专用网络。 这种动态路由是基于网络状态的,尽管这里有许多参数要考虑,但我们的重点是链路指标 。 在我们的上下文中,链接指标可以是与网络链接的状态或当前属性(例如,链接延迟)有关的任何内容。
并发探测链接指标
HALO (逐跳自适应链路状态最优路由)是我们的动态路由算法,部分依靠链路度量来计算其路由表。 这些指标由位于每个PoP (存在点)上的独立组件收集。 PoP是代表我们网络中单个路由实体的机器,它们通过链接连接并分布在形成我们网络的多个位置。 该组件使用网络数据包探测相邻计算机,这些邻居将反弹回初始探测。 链路等待时间值可以从接收到的探测中得出。 因为每个PoP都有一个以上的邻居,所以这种任务的本质是并发的:我们需要实时测量每个邻居链路的延迟。 我们负担不起顺序处理; 为了计算此指标,必须尽快处理每个探针。
![延迟计算图 latency computation graph](https://i-blog.csdnimg.cn/blog_migrate/26d19f565f82259df0d5deafc6218684.png)
序列号和重置:重新排序情况
我们的探测组件交换数据包,并依靠序列号进行数据包处理。 这旨在避免处理分组重复或乱序分组。 我们的第一个实现依靠特殊的序列号0重置序列号。 这样的数字仅在组件初始化期间使用。 主要问题是我们正在考虑一个始终从0开始的递增序列号值。组件重新启动后,可能会发生数据包重新排序,并且数据包可以轻松地用重置之前使用的值替换序列号。 这意味着随后的数据包将被忽略,直到达到重置之前使用的序列号。
UDP握手和有限状态机
这里的问题是组件重新启动后序列号是否正确一致。 有几种方法可以解决此问题,在讨论了我们的选项之后,我们选择实现带有清晰状态定义的三向握手协议。 此握手在初始化期间通过链接建立会话。 这样可以确保节点通过同一会话进行通信并为其使用适当的序列号。
为了正确实现这一点,我们必须定义一个具有清晰状态和过渡的有限状态机。 这使我们能够适当地管理握手形成的所有极端情况。
![有限状态机图 finite state machine diagram](https://i-blog.csdnimg.cn/blog_migrate/1663b1d07fc873b624df4fc458df6154.png)
会话ID由握手启动器生成。 完整的交换顺序如下:
- 发件人发送SYN(ID) 包。
- 接收器存储接收到的ID并发送SYN-ACK(ID) 。
- 发送方接收SYN-ACK(ID)并发出ACK(ID) 。 它还开始发送从序列号0开始的数据包。
- 接收者检查最后收到的ID 并接受ACK(ID) 如果ID匹配。 它还开始接受序列号为0的数据包。
处理状态超时
基本上,在每种状态下,您最多都需要处理三种类型的事件:链接事件,数据包事件和超时事件。 这些事件会同时显示,因此在这里您必须正确处理并发。
- 链接事件是链接更新或链接更新。 这可以启动链接会话或中断现有会话。
- 数据包事件是控制数据包(SYN / SYN-ACK / ACK)或仅仅是探测响应。
- 超时事件是针对当前会话状态的预定超时到期后触发的事件。
这里的主要挑战是如何处理并发超时到期和其他事件。 在这里,人们很容易陷入僵局和竞争状况的陷阱。
第一种方法
该项目使用的语言是Golang 。 它确实提供了本机同步机制,例如本机通道和锁,并且能够旋转轻量级线程以进行并发处理。
![地鼠一起砍 gophers hacking together](https://i-blog.csdnimg.cn/blog_migrate/3eb290acd4331199d8096925133d994c.png)
地鼠一起砍
首先,您可以设计一个代表我们的会话和超时处理程序的结构 。
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:
a)您收到一个控制数据包,现在您要取消注册的超时并进入下一个会话状态。 (例如收到SYN-ACK 发送SYN之后 )。
b)您调用timeout.Cancel() ,它调用了timer.Stop() 。 (请注意,Golang计时器停止不会阻止已过期的计时器运行。) - 线程2:
a)在该取消调用之前,计时器已到期,并且回调即将执行。
b)执行回调,它计划新的超时并更新全局映射。 - 线程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](https://i-blog.csdnimg.cn/blog_migrate/da93ebd2a33c8f2b8c211a18620e5193.png)
有人在听吗?
特雷弗·佛瑞(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](https://i-blog.csdnimg.cn/blog_migrate/007e2d01e529cff7fa7625d7c0b764fe.png)
别忘了同步地鼠的工作
缺少条件检查
在您不能仅依赖于锁独占性的情况下,需要条件检查。 我们的情况有些不同,但是核心思想与条件变量相同。 想象一下一个经典情况,您有一个生产者和多个消费者使用一个共享队列。 生产者可以将一项添加到队列中,并唤醒所有消费者。 唤醒呼叫意味着队列中有一些数据可用,并且由于队列是共享的,因此必须通过锁来同步访问。 每个消费者都有机会抓住锁; 但是,您仍然需要检查队列中是否有项目。 需要条件检查,因为在您获取锁时您不知道队列状态。
在我们的示例中,超时处理程序从计时器到期中收到了“唤醒”调用,但是在继续进行回调执行之前,它仍然需要检查是否已向其发送了取消信号。
![地鼠训练营 gopher boot camp](https://i-blog.csdnimg.cn/blog_migrate/a7aa5a1fd03ae1a62c2f3dcd4d598a93.png)
如果您唤醒多个地鼠,可能需要进行条件检查
死锁
当一个线程被卡住,无限期地等待一个信号唤醒时,就会发生这种情况,但是这个信号永远不会到达。 通过停止整个程序执行,这些程序可以完全杀死您的应用程序。
在我们的例子中,这是由于多次发送调用到一个非缓冲且阻塞的通道而发生的。 这意味着仅在同一通道上完成接收后,发送调用才会返回。 我们的超时线程循环正在及时在取消通道上接收信号; 但是,在收到第一个信号后,它将中断环路,并且再也不会从该通道读取数据。 其余的呼叫者将永远被卡住。 为避免这种情况,您需要仔细考虑代码,谨慎处理阻塞调用,并确保不会发生线程饥饿。 在我们的示例中,解决方法是使取消调用成为非阻塞的,因为我们不需要阻塞调用。
翻译自: https://opensource.com/article/19/12/go-common-pitfalls
golang gui编程