【问题标题】:Haskell Datagram UDP Sockets: Receiving works but sending doesn'tHaskell 数据报 UDP 套接字:接收有效,但发送无效
【发布时间】:2020-04-04 22:17:59
【问题描述】:

为了了解 Haskell 中网络通信的工作原理,我正在实现一个类似 echo 的简单 UDP 服务器。 它应该通过本地主机(端口 7331)上的 IPv6 进行侦听,接收消息并将它们与另一个字符串连接起来回显给发送者。

{-# LANGUAGE OverloadedStrings #-}

module Main where

import Network.Socket hiding (send, sendTo, recv, recvFrom)
import Network.Socket.ByteString
import Control.Concurrent
import Control.Exception
import Control.Monad (forever)
import System.IO (
        IOMode (ReadWriteMode)
        , hPutStrLn
                 )
import qualified Data.ByteString as BS

main = do
    sock <- socket AF_INET6 Datagram defaultProtocol
    let hints = defaultHints { addrFamily = AF_INET6, addrSocketType = Datagram}
    serverAddr <- addrAddress . head <$> getAddrInfo (Just hints) (Just "::1") (Just "7331")
    print serverAddr
    bind sock serverAddr
    print sock
    forever $ do
        receivedStuff <- recvFrom sock 65535    -- blocks
        forkIO $ bracket newSendSocket close (serveReceive receivedStuff)

serveReceive :: (BS.ByteString, SockAddr) -> Socket -> IO ()
serveReceive (msg, fromAddr) sendSocket = do
    putStrLn $ "Got message " ++ show msg ++ " from " ++ show fromAddr
    sendTo sendSocket ("Hi, thx for " `BS.append` msg) fromAddr
    putStrLn "sent response"
    return ()

newSendSocket :: IO Socket
newSendSocket = socket AF_INET6 Datagram defaultProtocol

我正在使用 netcat 测试服务器的功能:nc -u -6 "localhost" 7331

服务器接收消息并能够将它们放入标准输出。但响应从未出现在 netcat 中。

知道我在这里做错了什么吗?据我所知,在使用 sendTo 发送数据之前,不需要绑定数据报套接字 (bind) 或 connected。

【问题讨论】:

    标签: sockets haskell udp


    【解决方案1】:

    为什么要打开一个新的套接字进行发送?那是没有必要的。如果您只是重新使用现有的套接字,那么一切正常。或者,我们可以使用 wireshark 并查看。

    使用新的套接字进行响应(相关代码)

    sendTo 返回值表明发送了正确的字节数。查看 Wire Shark,我们看到了网络上的响应,但响应随后被拒绝,并显示 ICMP Destination Port Unreachable(nc 不接受来自其他端口的消息)。

    使用原插座*

    如果我们不专门为响应创建一个套接字,而只是重新使用原始套接字,那么事情会按照您的预期运行 - 很好。

    【讨论】:

    • > 为什么要打开一个新的套接字进行发送?这样做的原因是我想将消息的解析、处理和回复分叉到它们自己的线程中,以便能够同时处理许多消息。 Network.Socket 的文档说明: >正确的编程模型是一个 Socket 由一个线程处理。如果多个线程同时使用一个 Socket,就会发生意想不到的事情。所以至少我不能一次将套接字传递给多个线程。总是创建一个新的接收套接字并绑定它也可能是个坏主意。
    • 所以我要么需要另一个选项来同时发送独立于接收线程的回复,要么需要弄清楚是否可以在真实的客户端实现中规避 ICMP Destination Port Unreachable 错误。到目前为止仍然感谢,也许你有一些并发处理 UDP 数据包的经验。我发现的大多数示例都处理 TCP,它更容易处理。
    • 可能的方法是只使用未连接的数据报套接字。那时我无法用 netcat 测试程序,但无论如何我自己都在编写相应的客户端。 man 2 connect: > 如果套接字 sockfd 是 SOCK_DGRAM 类型,那么 addr 是默认发送数据报的地址,也是接收数据报的唯一地址。
    • 文档中的注释对我来说似乎是无稽之谈。我推测它是在考虑 TCP 连接的情况下编写的,并且指的是多个线程从流中读取和/或写入的相当明显的问题。查看Network.Socket 源,我发现多个线程使用sendTo 通过同一个UDP 套接字发送并发消息没有问题。
    【解决方案2】:

    好的,也许我应该让我的评论成为答案,以防有人能够提出Network.Socket 不是线程安全的案例,并且可以跟进一些令人信服的 cmets。

    Network.Socket 模块是围绕常用Berkeley sockets API 的薄层。对于 Berkeley 套接字,使用单个 UDP 套接字和多个线程进行并发 recvFromsendTo 调用或两者都没有问题。对于 UDP 连接,套接字本身在很大程度上是无状态的,除了它绑定的本地 IP 地址和端口。具体来说,recvFromsendTo 调用对于 UDP 来说基本上是“原子的”——两个同时传出的数据报不可能“交错”,传入的数据报也不可能在线程之间分成小块。

    UDP 协议不保证所有数据包都会被传送,也不保证它们不会重复,因此您的应用程序需要准备好将(完整的)数据报传送到更多比一个线程或零个线程,但这与线程安全无关。这只是 UDP。

    如果 Network.Socket 添加了缓冲层或其他一些复杂的处理,那么它可能不是线程安全的,即使对于 UDP 也是如此,但是查看代码,我看到 recvFromsendTo 无非就是内存分配和等效的套接字 C 调用。

    鉴于此,多线程 UDP 回显服务器最合理的架构是使用单个接收线程,该线程无条件地为每个请求分派一个新线程。您可能不会在带有 pthreads 的 C 程序中使用这种架构,因为如果您要处理大量请求,这些线程非常昂贵,但 GHC forkIO 线程是轻量级的,所以分叉,比如说,几千个应该'不是问题。

    module Main where
    
    import Network.Socket hiding (recvFrom, sendTo)
    import Network.Socket.ByteString
    import Control.Concurrent
    import Control.Monad
    import Data.ByteString (ByteString)
    
    main :: IO ()
    main = do
      sock <- socket AF_INET6 Datagram defaultProtocol
      addr:_ <- getAddrInfo (Just defaultHints
                              { addrFamily = AF_INET6, addrSocketType = Datagram })
                            (Just "::1") (Just "7331")
      bind sock (addrAddress addr)
      forever $ do
        result <- recvFrom sock 4096
        forkIO $ worker sock result
    
    worker :: Socket -> (ByteString, SockAddr) -> IO ()
    worker sock (msg, client) = do
      threadDelay 1000000   -- simulate some processing
      void $ sendTo sock msg client
    

    在我最初的回答中,除了上述方法之外,我还建议了一种替代架构,在接收-发送循环中使用固定数量的工作线程,如下所示:

    import Network.Socket hiding (recvFrom, sendTo)
    import Network.Socket.ByteString
    import Control.Concurrent
    import Control.Monad
    
    main :: IO ()
    main = do
      sock <- socket AF_INET6 Datagram defaultProtocol
      addr:_ <- getAddrInfo (Just defaultHints
                              { addrFamily = AF_INET6, addrSocketType = Datagram })
                            (Just "::1") (Just "7331")
      bind sock (addrAddress addr)
      replicateM_ 16 $ forkIO $ worker sock
      forever $ threadDelay longtime
      where longtime = 10^12
    
    worker :: Socket -> IO ()
    worker sock = forever $ do
      (msg, client) <- recvFrom sock 4096
      threadDelay 1000000   -- simulate some processing
      sendTo sock msg client
    

    这样做的好处是,即使有大量请求同时进入,同时运行的工作人员数量也有一个预先指定的上限。 (在它们开始被丢弃之前请求“激增”的实际上限将高于工作人员的数量,因为即使所有工作人员都被占用,O / S也会缓冲数据包。)上游指出的一个缺点是,是所有的 Haskell 线程(在上面的例子中,有 16 个)都被唤醒,导致一堆 recvFrom 调用,其中只有一个得到响应。但是,使用这种方法的全部意义在于限制同时请求的数量,因此我们并不需要几十个额外的系统调用。

    事实上,无论采用哪种方法,在单个套接字上操作都不存在线程安全问题。

    【讨论】:

    • 谢谢,您的解释确实有道理。我可能会打开一个上游问题进行澄清。但是,安全地为每个发送线程创建一个新的未连接的数据报套接字会有什么缺点呢?只要我不connect 一个套接字,它就应该接受来自所有源地址的数据包。当我同时编写客户端和服务器时,我可以让它们使用未连接的套接字。或者创建这么多瞬态套接字会导致许多昂贵的上下文切换?
    • 主要缺点是创建和销毁套接字的开销导致性能下降,以及在负载下可能会耗尽文件描述符。因为您将从随机端口响应,所以如果您超出环回接口,您也可能无法跨越防火墙或存在 NAT(对于 IPv6 不常见,但并非闻所未闻)。
    • 根据上游的说法,多个线程阻塞在同一个socket上需要IO管理器一次性唤醒,导致性能不佳。所以文档注释不是由线程不安全引起的,而是由线程性能下降引起的。 >是的,send、sendTo、recv 和 recvFrom 是线程安全的,但是,考虑到 IO-manager 胶水,多个线程竞相从同一个套接字读取性能很差。使用 ReusePort(在服务器上)可以获得更好的结果。 github.com/haskell/network/issues/444#issuecomment-612574209
    • 我已经根据上游的 cmets 更新了我的答案。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2013-08-28
    • 2010-11-13
    • 2013-11-19
    • 1970-01-01
    • 2016-01-07
    • 2013-06-18
    相关资源
    最近更新 更多