前几篇的文章发表后,有网友留言说没有涉及到阻塞的问题吗?在 socket 的编程当中,这确实是个很重要的问题。结合目前我们文章的内容进度,我们来看看为什么说阻塞概念很重要。
接着上篇的内容,当我们发送了 ehlo 命令之后就要接收服务器的返回了。这个地方是一个很容易出错的位置,一般的网络命令都是发送一条命令接收一条回复,这很容易让初学好者以为每个命令都是一行内容,进而在代码中进行了错误的处理。而实际上无论是命令还是对命令的应答都是有多行的情况,如果对 socket 机制不了解,那就会说:那就读取完所有的行呗。但在实际情况中“读取所有的行”是不可能完成的任务,因为我们前面已经说过了 socket 实际上是字节流,并没有一个行结束或者一个数据包结束了的概念(当然底层实现会有 ip 包)。所以在网络编程中有一个重要的事情,那就是怎样定义一个数据包算是结束了?这是每个通讯协议都要解决的问题(我个人认为是每个协议中最为重要的内容),在每个通讯协议中做法都不同,而且方法那是五花八门,用现在的话来说成是脑洞大开都不为过。我印象最深的是前几个月写的一个公司的专有 http 包转发服务器时意外发现的一个 http 包的结束表示方法,很惭愧地说,我接触 http 协议很多年了,甚至写过好几个真正能用的 http 服务器实现,却不知道这个方法 ... 这也不能怪我,加上这个方法我都数不清 http 到底有多少种表示一个包结束的方式了(是 http 中的 Transfer-Encoding chunked,以后有机会再给大家详细介绍)。 回到 smtp 协议上来,前面的文章中其实我们已经提到过 ehlo 命令的响应是怎样处理的。它的回应类似于这样:250-Eemail server250 AUTH LOGIN
在 rfc 文档中就有说明,读取到有 250 而且没跟的 "-" 符号时就可以了。如果我们没有正确处理一直读取下去,那么就会触发 socket 中一个著名的问题:阻塞。就是程序整个不动弹了,除了把它的进程杀死以外没有别的任何办法。可以用以下 java 代码模拟(基于上一篇的代码):
//发送一个命令 //SendLine("EHLO"); //163 这样是不行的,一定要有 domain //SendLine("EHLO" + " " + domain); //domain 要求其实来自 HELO 命令//HELO//收取一行 line = RecvLine(); System.out.println("recv:" + line); //收取一行 line = RecvLine(); System.out.println("recv:" + line);
这里我们设想,先尝试读取 100 行数据,当没有行内容的情况下就提前跳出,想是服务器的响应内容读取完了。这个思想是没问题的,可惜现实下是行不通的。原因就是 socket 的读取函数默认情况下会一直等待,一直到有数据为止,如果一直没有数据呢?那就一直在等,整个程序就停止响应了,除非对方主动把连接给断开了,或者是网络断线了。这就是为什么安卓程序现在不允许在主线程中直接调用 socket 的最主要原因:因为很多初学者处理不好这个问题,常常会让程序卡死,那干脆就强制不让他们放在主线程了。
要解决这个问题,java 中只需要在连接后多加一个函数调用:socket = new Socket(host, port); socket.setSoTimeout(10000);//设置超时,单位为毫秒
以原始 socket 方式处理的话,传统上则有好几种做法:
1.是设置 socket 的超时;2.接收前使用 select 函数判断是否可以收发数据;3.使用非阻塞的 socket;4.使用线程。
其中第一种方法最简单,连接后简单的调用一下相关函数就一了百了(上面的 java 代码就是如此),不过有些简化版本的 socket 环境不一定支持;而 select 函数则最传统,可以在决大多数环境下使用;前两种都要配合线程使用才好,而非阻塞 socket 的方式则完全不会阻塞主线程,不过编程的复杂度会直线上升级,不适合初学者。所以我们这里简单地使用 select 函数来完成超时判断,实现代码如下:
//是否可读取,时间//超时返回,单位为秒int SelectRead_Timeout(SOCKET so, int sec){ fd_set fd_read; //fd_read:TFDSet; struct timeval timeout; // : TTimeVal; int Result = 0; FD_ZERO( &fd_read ); FD_SET(so, &fd_read ); //个数受限于 FD_SETSIZE //timeout.tv_sec = 0; //秒 timeout.tv_sec = sec; //秒 //linux 第一个参数一定要赋值 if (_select( so+1, &fd_read, NULL, NULL, &timeout ) > 0) //至少有1个等待Accept的connection Result = 1; return Result; }//
这里要注意的是 windows 的写法和 linux 的写法是小有差异,大家一定要小心。
顺便介绍一下其他几种方法的实现吧。前面 java 代码的超时本质就是用 setsockopt 来实现的,对于 C 语言来说类似于这样://设置发送超时setsockopt(socket,SOL_SOCKET,SO_SNDTIMEO, (char *)&timeout,sizeof(struct timeval));//设置接收超时setsockopt(socket,SOL_SOCKET,SO_RCVTIMEO, (char *)&timeout,sizeof(struct timeval));
/** * Enable/disable SO_TIMEOUT with the specified timeout, in * milliseconds. With this option set to a non-zero timeout, * a read() call on the InputStream associated with this Socket * will block for only this amount of time. If the timeout expires, * a java.net.SocketTimeoutException is raised, though the * Socket is still valid. The option must be enabled * prior to entering the blocking operation to have effect. The * timeout must be > 0. * A timeout of zero is interpreted as an infinite timeout. * @param timeout the specified timeout, in milliseconds. * @exception SocketException if there is an error * in the underlying protocol, such as a TCP error. * @since JDK 1.1 * @see #getSoTimeout() */ public synchronized void setSoTimeout(int timeout) throws SocketException { if (isClosed()) throw new SocketException("Socket is closed"); if (timeout < 0) throw new IllegalArgumentException("timeout can't be negative"); getImpl().setOption(SocketOptions.SO_TIMEOUT, new Integer(timeout)); }
ioctlsocket(so, FIONBIO, &arg);
我又不得不说,我很惭愧非阻塞的 socket 概念我是工作好几年以后才听说的。准确的说是毕业不久后就知道了,不过一直以为只是 windows 下的一种扩展,因为 windows 对 socket 的扩展很多所以也并没有多在意。后来到了一家公司面试,说他们主要用非阻塞的 socket 时才知道还能实用...... 在以后的工作当中渐渐的发现,有些工作环境下没有非阻塞 socket 还真不好实现。所以现在非阻塞的 socket 基本上也是各个平台都支持了的。不过非阻塞的实现难度基本上是直接上升,我们这里暂时就不给出示例了。这种方法的特点是 socket 被设置为非阻塞后,所有的接收和发送都会立即返回,不管是否成功。
根据以上思想修改后的 C 语言代码多了1个函数://读取多行结果lstring * RecvMCmd(SOCKET so, struct MemPool * pool, lstring ** _buf){ int i = 0; int index = 0; int count = 0; lstring * rs; char c4 = '\0'; //判断第4个字符 lstring * mline = NewString("", pool); for (i=0; i<50; i++) { rs = RecvLine(so, pool, _buf); //只收取一行 mline->Append(mline, rs); LString_AppendConst(mline, "\r\n"); //printf("\r\nRecvMCmd:%s\r\n", rs->str); if (rs->len<4) break; //长度要足够 c4 = rs->str[4-1]; //第4个字符 //if ('\x20' == c4) break; //"\xhh" 任意字符 二位十六进制//其实现在的转义符已经扩展得相当复杂,不建议用这个表示空格 if (' ' == c4) break; //第4个字符是空格就表示读取完了//也可以判断 "250[空格]" }// return mline;}//
另外 RecvLine 函数中也多了几行内容:
canread = SelectRead_Timeout(so, 3);//是否可读取,时间//超时返回,单位为秒 if (0 == canread) break;
具体代码有点多,仿照才惯例,请大家到以下 github 地址下载吧:
另外,虽然这个系列的文章说的是邮件发送和收取,不过其中涉及到的知识都会应用于其他的网络通讯协议,了解了邮件相关的,象什么 ftp、http 等协议其实也基本上贯通了。其实我个人也是打算将自己所了解的网络编程相关的知识都放到这系列的文章中来,因为象邮件涉及到的 base64、mime 编码这样的内容其实都是在其他协议中广泛使用的。大家看完这系列的文章后写个 http 程序也完全不是问题。所以请大家多多关注吧!有了前面这几篇的文章和代码,大家其实已经可以用程序写出完整的邮件发送代码了.这和真实的邮件客户闻风而动发送过程也差不多了(还差的主要是两点: base64 编码和 mime 过程,我们会在后面的文章详细说明).
--------------------------------------------------版权声明:
本系列文章已授权百家号 "clq的程序员学前班" . 文章编排上略有差异. 百家号目前对文章中的代码转换得很厉害,因此推荐大家在博客园这边查看原始的代码.