网络和套接字指导原则
2003年9月12日 作者:高通 移动锋行
线程问题
开发者在 Windows 或 Unix 平台上的应用程序中使用网络时通常使用编块调用。此类调用只有在操作完成或失败时才返回。例如,通常会使用编块写入调用。它只有在所有数据成功发送或出错时才会返回。同样,许多程序员对编块套接字使用 fgets() 等调用。在接收到完全行前它会等待。
对于本地 I/O(如磁盘读写),编块套接字类似于编块调用,因此被广泛使用。但是,与本地磁盘 I/O 不同,网络会继承性地受到不可预见的延迟的制约。这些延迟在执行任何操作时都可能发生,而且可能持续几分钟时间。
要防止某应用程序被锁定且在编块时不响应用户事件或其它事件,开发者一般会使用线程;通常,每个网络连接一个线程。就象使用编块调用一样,线程具有熟悉的线性范型的优点,但会增加开销和复杂性。除了内存和其它系统资源,线程还会使用不太明显的资源,如用于维持状态、同步等的结构空间。线程会极大地增加应用程序的复杂性,从而增加代码和内存。应用程序越复杂,出现故障的几率也就越高。线程会在访问共享对象时引起同步问题。仅此领域就有很多陷阱。
BREW™ 的回调不编块策略在一种考虑使用更简单程序的模型中提供等效功能,因此更容易写入和调试(因而更加稳定),而且使用的资源更少。
因为 BREW 不支持线程,BREW API 更加简单(创建、销毁、启动、停止和同步线程时不需要调用)。BREW 层本身更小、更高效且更简单(因此更可靠)。这样,将 BREW 移植到新的芯片集和手持设备时就会缩短所需时间、降低所需成本、节省所需精力。
应用程序(在很多情况下,BREW 层本身)会通过避免各种多线程问题而受益。问题包括:
对共享数据的多线程访问导致极难察觉的故障
难以(或无法)充分测试多线程代码,尤其是在跨开发环境中
堵塞在编块调用中的线程的响应性问题 - 通常这些情况中的某些情况被忽视,因此,线程(依次为应用程序)无法不经某些随意的延迟而安全终止。这是移动手持设备资源严格受限的环境中存在的问题。
一般来说,不编块实施使用的内存更少(尤其包括栈内存),而且处理中断更加容易和简洁。
与不编块 API 相关的一个潜在困难包括处理为编块 API 编写的大块现有代码。不过,任何编块网络代码可以“机械地”转换成不编块版本。它不是当前有软件执行此操作意义上的机械,而是进程可以体现在象编译器一样复杂的软件中意义上的机械。转换过程可能十分耗时,但是如果开发者清楚地理解并重视基本的等效概念,则即使是较大的代码库也可以成功进行转换。
转换不编块代码
转换过程使用面向对象术语进行描述,但可以根据需要用 C 或 C++ 实施。
每个编块函数 - 即直接或间接等待外部事件的函数 - 可以转换为有三个或三个以上方法的对象(成员函数):
构造函数
析构函数
功函数
以下操作应该可以确保有效转换:
定义一个有持久状态的对象/结构。寿命跨编块操作的所有有值本地变量必须移动到该对象(这可能包括原始函数的变量)。因为该对象在堆上分配,其成员会在返回控制权时仍然存在。
创建一个“新”函数来分配和初始化状态对象。该函数以传递到原始编块函数的参数为参数。它还有两个描述在完成时调用的回调函数的两个新参数(函数指针和空指针)。
创建一个“功”函数,包含原始编块函数的主要部分。它有一个参数:状态对象的指针。功函数由构造函数调度或调用。
功函数是一个状态机:它使用当前状态值来执行开关语句。它包含每个状态的情况语句标记。由原始代码中的编块调用分隔的每个操作系列有一个状态(和其它似乎有用的状态)。
调用时,功函数会继续处理原始函数的任务,直到它必须等待事件时为止(例如完成网络连接)。然后,它会保存对象中的状态,请求从适当的机制中回调并返回。再次调用时,它会从上次停止的位置继续(使用对象中存储的状态值作为其开关语句的索引)。这要求对函数的主要部分进行以下修改:
每个编块调用被启动异步操作、调度功函数自身以在完成时重新调用、记录当前状态并将控制权返回调用程序的代码序列替换。每个状态都有一个情况语句标记。
例如:
WaitOnKeypress();
变成:
KeypressNotify ( me, Object_Work );
me->nState = ST_WAITFORKEY;
return;
通知回调函数与功函数的原型不匹配的情况下,可以使用一个较小的助手函数。该助手函数与通知回调函数原型匹配,仅调用功函数。例如,向 BREW Connect() 回调函数传递结果值。助手函数可以在状态对象中存储结果值并调用功函数。
每个以前编块的调用都被赋予了一个状态值。在功函数开始处,使用状态变量的开关将控制流导向相应的标记。根据情况,可以只让开关语句对每个状态执行一个 goto 语句。这可以使您保持编块代码的原始结构(包括“for”和“while”循环)。不过,将代码移动到开关语句一般更容易进行保持和验证。(如果一个情况语句的代码较长,您可以将其移至 INLINE 函数。)
示例:
switch ( me->nState )
{
...
case ST_WAITFORKEY:
...
break;
...
}
功函数通常在事件完成时由 BREW 调用,而原始函数仅在原始应用程序执行流程中被调用。因此,功函数会调用一个回调函数来通知其客户端(“调用程序”),而不是退出将控制权返回调用程序并恢复原始控制流(如编块函数)。如果原始函数返回一个值,最好通过向构造函数传递一个结果存储位置的指针来处理。
一个“delete”函数会销毁该对象 - 在任何时候停止操作 - 释放分配的所有资源并取消可能已调度的任何操作。该函数也应该由功函数调用,以在调用客户端回调函数前清除任何资源。
因为 BREW 没有线程和可重入功能,delete 函数仅可在功函数未激活时由客户端调用(已调度回调函数并退出)。这样就简化了 delete 和功函数,因为功函数不需为执行取消操作而锁定任何内容或进行测试,而 delete 函数仅须清除(包括取消功函数请求的回调函数)和释放资源。
客户端(原始调用程序)可能会响应按“取消”按钮的用户,直接调用 delete 函数,中途中断操作。
转换示例
下面是一个转换示例。颜色表示原始函数的某些关键组成部分以及在新版本中的相应部分。
持久变量(绿色)
编块/不编块调用转换(蓝色)
不编块版本的新变量/代码(红色)
原始编块版本:
下面是一个执行时间较长的简单编块函数。它省略了一些“#define”和错误检查并隐藏了许多无关的功能。一个“空”函数简化了该示例。(有返回值的函数首先应转换成空函数。)
void QueryDNS (const char *pszDomain, INAddr *paddrResult )
{
char *pcReq = NULL;
int cbReq = 0;
int cntTries = 0;
int s = socket();
int nRcvd = 0;
char buf [ 512 ];
pcReq = malloc ( 300 );
cbReq = DNSConstructRequest ( pcReq, pszDomain );
do
{
INAddr addr;
INPort port;
sendto ( s, DNSADDR, DNSPORT, pcReq, cbReq );
// recvfrom_timeout: 假定的阻塞函数,
// 与 recvfrom() 类似,但有明确的超时值
nRcvd = recvfrom_timeout ( s, &addr, &port, 3000,
buf, sizeof(buf) );
}
while ( nRcvd == TIMEOUT && ++cntTries < MAXTRIES );
if ( nRcvd > 0 )
{
*paddrResult = DNSReadResult ( buf, nRcvd );
}
#else
{
*paddrResult = INADDR_NONE;
}
free ( pcReq );
close ( s );
}
结果:
结果是下面的类定义(此处使用了具有面向对象约定的 C 代码)。使用了 goto 形式,而不是将代码嵌入 switch 语句,因为它要求继续使用更多的原始控制流(包括循环),因此更清楚地对转换进行了例示。不过,如果不使用 goto 而将代码移至 switch 语句将更加简洁,而且更容易维护和调试。
注意:还有待于执行许多明显的优化操作(例如,可以通过将状态 0 功函数移至 New 函数取消状态变量和开关、两个内存分配可以合并为一个、可以取消某些持久变量)。因为本例用于说明转换过程的一致性,这些优化操作可以省略。
对于异步套接字操作,使用完成时回调接口,而不是 BREW 套接字 API 的准备重试时回调接口,以说明更具有一般性的情况。不过,差异较小。
typedef struct
{
int nState;
CLIENT_CALLBACK *pAllDone;
void **ppClientPtr;
char *pcReq;
int cbReq;
int cntTries;
INAddr *paddrResult;
int nRcvd;
int s;
char buf [ 512 ];
} QueryDNS;
// 创建对象;此处包含所有的初始化代码
//
QueryDNS *QueryDNS_New ( const char *pszDomain,
INAddr *paddrResult,
CLIENT_CALLBACK *pAllDone,
void **ppClientPtr )
{
QueryDNS *me = (QueryDNS *) malloc ( sizeof(QueryDNS) );
me->pcReq = malloc ( 300 );
me->cbReq = DNSConstructRequest ( me->pcReq,
pszDomain );
me->cntTries = 0;
me->nState = 0;
me->paddrResult = paddrResult;
me->pAllDone = pAllDone;
me->ppClientPtr = ppClientPtr;
me->s = socket();
QueryDNS_Work ( me );
}
// 删除对象;此处包含所有的清除代码
//
QueryDNS_Delete ( QueryDNS *me )
{
// recvfrom_timeout_cancel() 和 sendto_cancel 是假定的
// 取消接收和发送操作的函数。
recvfrom_timeout_cancel ( me, QueryDNS_Work );
sendto_cancel ( me, QueryDNS_Work );
close ( me->s );
free ( me->pcReq );
free ( me );
}
void QueryDNS_Work ( void *pvCxt )
{
// 创建 pvCxt 的转换变量,从而便不
// 必转换 Work() 函数指针
QueryDNS *me = (QueryDNS *) pvCxt;
switch ( me->nState )
{
case 1:
goto querydns_st_1;
case 2:
goto querydns_st_2;
// case 0: 失败
}
do
{
INAddr addr;
INPort port;
sendto_asynch ( me->s, DNSADDR, DNSPORT, pcReq, cbReq,
me, QueryDNS_Work );
me->nState = 1;
return;
querydns_st_1:
// recvfrom_timeout_asynch: 假定的调用
// 成功或超时后调用的函数
recvfrom_timeout_asynch ( me->s, &addr, &port, 3000,
me->buf, sizeof(me->buf), &me->nRcvd,
me, QueryDNS_Work );
me->nState = 2;
return;
querydns_st_2:
}
while ( me->nRcvd == TIMEOUT && ++cntTries < MAXTRIES );
if ( me->nRcvd > 0 )
{
*me->paddrResult = DNSReadResult ( me->buf, me->nRcvd );
}
else
{
*me->paddrResult = INADDR_NONE;
}
me->pAllDone ( me->ppClientPtr );
QueryDNS_Delete ( me );
}
用法差异:
调用程序不调用 QueryDNS() 并期望它返回时结果有效,而是使用:
me->pqdns = QueryDNS_New ( pszDomain, &me->addr,
me, MyObj_DNSDone );
调用 MyObj_DNSDone() 时,me->addr 保存结果。最后,调用程序应正确支持操作的取消。这包括:
调用回调函数时将 me->pqdns 设置为 NULL
取消时,执行以下操作:
if ( me->pqdns != NULL )
{
QueryDNS_Delete ( me->pqdns );
me->pqdns = NULL;
}
为了获得额外安全,可以向 QueryDNS_Delete() 函数传递 me->pqdns 的地址,从而可以将它设置为 NULL 而不是依靠调用程序。
注意:原始的编块版本不支持取消操作。对编块 C/C++ 环境中中断的正确支持涉及其它变量及在每一直接或间接编块的调用后对它们的明确测试、中断系统级等待操作(如 connect())的 OS 特定方法以及跟踪需要中断的操作的其它“基础结构”(如 connect() 调用被埋藏在几级函数调用之下。)