异步 TCP - 第 1 部分
目标:
引言
我需要编写一个异步 TCP 接口,并很快发现了 Windows 类 CAsyncSocket
。然而,经过大量搜索和阅读,都没有找到关于它如何工作以及如何使用的完整描述。在学习基本知识的过程中,花费了大量的时间和精力,提出了许多问题。最终的结果是一个应用程序,可以让我轻松地了解这个类是如何工作的。
在第一部分中,我描述了 CAsyncSocket
类的工作原理以及如何在简单应用程序中使用它。第二部分介绍了实现这些类的核心代码。 在这里可以找到。
尽管这篇文章很长,而且找到我所需的信息如此困难,但我意识到基本概念如此简单时,感到非常惊讶。实际实现比预期的要容易得多。
全部四篇文章
补充说明。这是全部四篇文章的简短摘要和每篇文章的链接。
我已经完成了四篇关于异步 TCP/IP 和 Microsoft 的 ASyncSocket
类的文章。第一部分描述了必要的概念。第二部分描述了一个将服务器和客户端集成在单个项目和单个对话框中的项目。用户可以一步一步地进行事务处理。在第三部分和第四部分中,服务器和客户端被分离成一个解决方案中的两个项目。服务器和客户端可以运行在不同的计算机上。该项目引入了单个解决方案中多个项目的概念。它引入了从单独目录使用源代码的概念。如果您不熟悉 TCP/IP 和 ASyncSocket
,那么前两篇文章是必读的。如果您没有使用过单个解决方案中的多个项目,或者没有使用过附加包含目录,那么第三篇文章是必读的。如果这句话看起来很奇怪,请阅读第三部分。
下面列出了每篇文章的链接
必备组件
有许多关于 TCP/IP 操作的描述。本文假定读者理解 TCP/IP 操作的基本原理。同时假定读者能够创建 MFC 项目,用按钮和其他控件填充它,让这些控件执行某些操作,并显示结果。
注意
这是一个 CAsyncSocket
的演示应用程序。添加所有真正使其灵活且能处理任何可预见问题的代码将模糊基本原理。它不是防弹的。读者可能能够找到使其崩溃的方法。这是一个演示工具,而不是一个工作应用程序。
为什么是异步的?
许多应用程序可以一次只关注一件事。还有更多应用程序必须同时关注多件事情。有三种基本类型的操作。
- 同步:应用程序发起一个操作并等待结果。本质上,所有函数或方法调用都是同步的。
- 轮询:应用程序启动一个操作,继续处理其他事情,然后在稍后返回检查结果。轮询会消耗可能需要用到其他地方的资源。
- 异步:一个操作发起另一个操作,然后在等待一个事件触发另一件事情发生时,去做别的事情,这就是异步操作。
异步可以细分为两组或更多组。在 DEC(数字设备公司)操作系统 VMS 中,用户会发起一个操作,例如 I/O 操作。在调用参数中会有一个函数地址,当 I/O 完成时,该函数将被运行。当 I/O 操作完成时,操作系统会以比主任务稍高的优先级调用该函数。这是一种回调操作。它是事件驱动的。
在 Windows 中,当操作完成时,操作系统会将一个消息发送到应用程序。过一段时间,当应用程序再次运行时,应用程序的顶层会检查消息队列,找到该消息,然后调用一个方法。这同样是事件驱动的,但不是直接调用函数,而是操作系统设置一个标志来触发调用。这些被调用的方法就是稍后将看到的 On*()
方法。(注意:在短语 On*()
中使用通配符 * 来指代所有以 On
开头的多个方法。
从这个应用程序的角度来看,以及很可能与之相似的大多数应用程序的角度来看,用户是看不出区别的。但它们是不同的。
测试应用程序
在阅读了许多关于如何使用 CAsyncSockets
的描述之后,我仍然不知道如何使用它。其结果是本文介绍的 MFC 对话框应用程序。这个应用程序做的事情很少,但它是我用来将所有东西整合在一起,让它们协同工作,并观察它们如何工作的载体。这是在服务器向客户端发送一条消息后拍摄的屏幕截图。
它是一个相当繁忙的应用程序,但异步套接字绝非易事。
有三组控件,每组都有几列。左侧,有四个子列,是服务器。右侧,最后四列是客户端。中间是 C_Server_Send
,有三列。每组下方是状态部分。
服务器和客户端部分各分为两个部分
- 用于启动操作的按钮和显示该按钮点击结果的状态字段。
- 一列
On*()
计数器及其计数值。例如:标签OnAccept()
及其右侧的计数显示,当前设置为 1。稍后我们将讲到这一点。
在 C_Server_Send
组中,对话框变得拥挤,所以我省略了状态列。每个部分的底部是 WSA 错误代码的显示。简单地说,这是每个 TCP 操作的 Windows 状态。谷歌搜索了解更多信息。查找那个 10035 值,并保存一个链接到您最喜欢的列出所有这些值的网页。
底部是接收时间。启动后,用户点击按钮6:发送,服务器将时间发送给客户端。然后按下按钮7:接收,客户端捕获数据并显示它。在这种情况下,显示的接收时间是 HH:MM:SS.xxx。这个应用程序就是做这些事情。
注意按钮上的数字,1 到 7。我有时会忘记我在过程中的哪个步骤,所以按钮上标有操作顺序。要初始化服务器和客户端之间的连接,请按顺序选择按钮 1 到 5。要更新时间,重复按钮 6 和 7。6:导致服务器发送时间,7:导致客户端读取它。在这个学习应用程序中,很少有事情是自动发生的。每个主要步骤都通过自己的按钮激活。在实际代码中,这一点必须改变。
读者最终会注意到有未使用的按钮和未使用的显示值。在创建这个应用程序的过程中,以及通过阅读各种文章,我阅读了关于各种 On*()
方法及其重写的内容。我不明白它们的意思,所以我为每个方法提供了一个骨架方法和代码来计算它被调用的次数。
这是我第一次尝试 Windows 异步操作,我决定捕捉最小的功能级别。服务器很简单,只发送时间。客户端的简单性与服务器相匹配,只接收和显示时间。如果您想要更多,可以自己添加。
读者应该注意,这个应用程序是一个完整的 TCP/IP 应用程序,包含服务器和客户端。全部集成在一起,它与自身通信。我相信如果用户调整 IP 地址并在每台计算机上运行一个副本,它也可以在两台计算机上运行。但那是以后的事情。我发现将所有内容都放在一个应用程序中更简单。
CAsyncSocket 基本原理
在本节中,基本上省略了所有代码细节,描述了基本操作。请放心,关键代码部分的简单程度与此描述相同。鼓励读者在阅读此描述时参考图示。请记住,代码中的每个主要操作都由单独的按钮点击激活。要发送和接收第一条消息,只需要七次点击。
此应用程序中有三个类:C_Server
、C_Client
和 C_Server_Send_Time_Socket
。所有这三个类都派生自 Windows 类 CAsyncSocket
。所有对基类的引用都是对 CAsyncSocket
的引用,每个类都从此派生。这些类中的每一个都针对其特定目的进行了定制,但它们都使用相同的基类。
在所有 TCP/IP 操作中,都有一个服务器和一个客户端。通常,服务器拥有客户端想要的资源。在这个演示应用程序中,服务器拥有时间,并将其发送给客户端。仅此而已。
服务器初始化
应用程序的服务器端指示 C_Server
类执行其各种操作。
从服务器按钮1:初始化开始。C_Server
类告诉基类进行初始化。代码确实就是这么简单。基类 CAsyncSocket
负责所有细节。
服务器监听
按钮2:监听,将 C_Server
置于监听模式。基类执行必要的 Windows 调用,Windows 执行所有必要的操作来创建一个 TCP 端口并开始等待客户端连接。这就是阻塞式应用程序会停止的地方。所有其他活动都会停止,直到监听操作收到响应。通过异步操作,应用程序可以处理其他任务,例如与其他客户端通信。
在这一点上,我依赖读者理解到目前为止已经发生了什么。如果您不理解这些事件,请查找并阅读一些 TCP/IP 文档,然后返回本文。
客户端初始化
现在我们激活 C_Client
类。在客户端部分,按钮三3:初始化,为客户端这边执行相同操作。
客户端连接
选择按钮4:连接,以发送连接请求到服务器。代码几乎就是这么简单。Windows 负责构建和发送一个 TCP 消息的所有细节,该消息会找到服务器并说明:有一个客户端想连接到您的服务器应用程序。
服务器接受
这是事情变得有点困难的地方。这有点用词不当。这是很难找到良好解释的地方。
我通过暂时讨论标准的对话框操作来解释。创建按钮,称之为 Do_A
,最终会得到一个名为:OnButtonClickedDo_A()
的方法。当这个方法被调用时,意味着按钮 Do_A
被按下。这个函数应该包含要执行的代码。
在 Server
类中,方法 OnAccept()
并不意味着客户端的请求已被接受,而是意味着应用程序已识别出客户端的请求,并且程序员现在必须采取措施来接受该请求。它意味着您现在可以执行 OnAccept
操作了。在我阅读关于这个类的过程中,OnSomething()
方法的基本含义从“Something_Happened
”(发生了某事)转移到了“Now_You_Can_Do_Something
”(现在您可以做某事)了。用户必须理解每个方法的目的。希望下面的内容对此有所解释。现在我们回到对这个应用程序的讨论。
在这个最小的应用程序中,唯一要做的事情就是从 Server
发送时间给 Client
。在您的真实应用程序中,可能有很多事情要做。我们暂时保持简单。
OnAccept()
方法除了增加 OnAccept()
的计数器并更新显示外,不做任何事情。以下是按下客户端按钮四4:连接后应用程序的屏幕截图。按钮 5 尚未被点击,只点击了前四个按钮。
请注意,服务器按钮 1 和 2 旁边显示 OK,客户端 3 和 4 旁边也显示 OK。在服务器部分,字段 OnAccept()
已从 0 变为 1。它已被调用。服务器接受按钮的状态字段仍设置为初始值状态。该按钮尚未被选择。另请注意,在客户端部分,OnConnect()
和 OnSend()
的计数器字段都显示 1。这个简单的应用程序忽略了这两者。但是:对于更高级的应用程序,您需要知道这些函数是否已被调用,以及何时被调用。最终,您可能需要了解它们,但不是现在。
有趣的是,客户端方面发生了一些事情,但服务器方面尚未完成其任务。您可以暂时忽略 WSA 错误代码,或者查找它并了解其含义。(谷歌搜索 wsa error 10035)现在轮到按钮五了。
关于 On*() 方法
在介绍按钮五之前,On*()
方法值得进行简短的功能解释,但细节推迟到第二部分。Windows 类 ASyncSocket
必须用作您创建的类的基类。在该类中有一组必须重写的方法。此列表包括
OnAccept()
OnClose()
OnConnect
OnOutOfBandData()
OnReceive()
OnSend()
可能还有其他我不知道的。在实际应用程序中,您将在其中一些或所有方法中编写代码来完成各种任务。在这个演示应用程序中,这些方法中的代码只是计算该方法被调用的次数并显示它。如果您使用此应用程序编写更多测试代码,并且当您触发调用这些方法时,计数器将显示已发生。添加这些显示字段和驱动它们的代码确实会给应用程序增加不少混乱,但现在应用程序显示了哪个 On*()
被调用以及何时被调用。商业广告时间结束,我们回到预定节目。
服务器接受
现在我们看到 OnAccept()
计数设置为 1
,我们就知道是时候接受客户端请求了。这里有很多活动。
需要更多的准备性解释。
服务器应用程序很少只为一个客户端而创建。它几乎总是有能力同时为多个客户端服务。在这个应用程序中,只能有一个客户端,但我们仍然必须经历必要的流程,就好像可能有许多客户端一样。
在讨论的这一点上,C_Server
类已经执行了监听客户端请求的任务。下一个任务是准备与特定客户端通信。第三个类,C_Server_Send_Time_Socket
,用于进行服务器和客户端之间持续的对话。这个类实现用于将时间发送给客户端。
回到之前的屏幕截图,用户点击了按钮 4,并且调用了 OnAccept()
函数。计数器向我们展示了这一活动。该方法的目的和含义是
客户端已启动与服务器的连接。现在是时候处理该连接了。
下一步是按钮5:接受。(暂不要点击。)按下此按钮会调用 C_Server Accept()
方法,该方法有两个基本操作:
- 创建
C_Server_Send_Time_Socket
的一个实例 - 将该实例与新的客户端连接关联起来。
每个服务器都可以与多个客户端通信。对于每个客户端,必须有一个 C_Server_Send_Time_Socket
类的实例。按钮5:接受执行这两个操作。请记住,这个有限的演示应用程序只能连接一个客户端。
首先,一个警告。在实际代码中,您几乎肯定会将 OnAccept()
方法编程为执行此处由按钮5:接受执行的操作。这个演示应用程序提供了逐个步骤地执行每个主要操作的能力。
我们的按钮5:接受(在点击它之前请继续阅读)首先创建一个 C_Server_Send_Time_Socket
类的新实例。(一行代码。)然后它调用基类方法 Accept()
。对该调用的参数是刚刚创建的 C_Server_Send_Time_Socket
类的实例。在基类的范围内,Windows 将该对象与刚刚发送连接信号的客户端关联起来。这构成了 accept()
方法核心的两行代码。
作为 Accept()
调用的结果,C_Server_Send_Time_Socket
的新实例已连接到客户端,并准备好向客户端发送数据。同时,如果您的服务器将连接到多个客户端,您将添加代码来回收 C_Server
并准备接收下一个客户端。在这个演示应用程序中,我们现在离开 C_Server
,切换到 C_Server_Send_Time_Socket
。
准备向客户端发送数据
现在点击5:接受。屏幕上没有太多变化。
唯一的变化是,在 C_Server_Send
下,OnSend()
的计数已增加到 1。
同样,与我们熟悉的 OnButtonClicked*()
方法不同,对 OnSend()
的调用并不意味着已经发送了什么。它意味着 C_Server_Send_Time_Socket
的这个实例现在可以发送数据了。
发送数据到客户端
在这个演示应用程序中,客户端不请求任何东西,服务器也不自动发送任何东西。我考虑在 C_Server_Send
中设置一个计时器,并定期更新时间。相反,我选择了简单。每次点击6:发送时,都会从 Windows 捕获时间并将其发送给客户端。
现在点击6:发送。
结果可能看起来有点平淡无奇,但这已经是服务器端的惊人成果。它已将时间发送给客户端,并且客户端的 OnReceive()
计数器已增加到 1。这次当 Windows 安排调用 OnReceive()
时,它并不意味着可以接收数据,而是意味着 Windows 已经从客户端接收到了一些数据,现在客户端可以从 Windows 获取或接收该数据。
在您的实际应用程序中,您将在 OnReceive()
方法中编写代码来捕获数据并对其进行处理。在这个演示应用程序中,我们只观察到有一些数据可以接收。注意用词的谨慎。服务器发送的数据已准备好,但应用程序尚未接收。
顺便说一句:send
方法有一行代码用于获取时间,一行用于获取大小,还有一行用于发送它。只需要三行核心代码。
从服务器获取数据
现在点击7:接收。客户端类转到其基类并调用 Receive( buffer, size, flags)
。这是应用程序完成服务器到客户端数据路径的地方。现在客户端拥有了数据,可以对该数据进行任何需要处理的操作。(一行核心代码。)当您执行此操作时,您将在对话框底部看到时间更新。本文的第一个屏幕截图显示了应用程序在数据发送和接收后的样子。
点击按钮 6 和 7 重复操作。在点击 7 之前点击6两次,看看效果。在未点击6:发送的情况下点击7:接收。然后根据您自己的结论来推断 Windows 中发生的事情。
在继续阅读第二部分之前,请仔细阅读本文几遍,并确保您理解基本概念。