Unix网络篇(1)一个典型的TCP Socket通信例子

我觉得能用TCP的地方就不用UDP。本文是一个基本的TCP通信例子,麻雀虽小,五脏俱全。包括处理SIGCHLDSIGPIPE和捕获EINTR错误,也有对TCP字节流的粘包处理和缓冲区讨论,还有采用fork的并发服务器设计,引用计数等。

1 服务端

服务器端主要流程是,socket->bind->listen->accept->fork。有几点需要注意。

1.1 处理SIGCHLD信号

主要是为了避免僵死进程。什么是僵死进程?就是子进程提前退出了,但是父进程中没有等待wait子进程。(此时,可能父进程还在,也可能父进程更早退出了)。它会带来一些列问题,其中主要是

  • 子进程资源没释放,句柄等没回收,会造成进程号达到上限,无法产生新进程

以下这句话不对,僵死并不是因为父进程挂掉了,而只是单纯的子进程资源没回收。 举个例子,如果服务器主进程listen进程挂掉了,那么所以子进程将会僵死(当然,init进程会处理该问题)。我们显然不愿意留存僵死进程,让init来处理。

解决办法 所以,捕获SIGCHLD信号,使用waitpid来等待(不是使用waitwaitpidwait多了一个参数int options),可以显示指定为WNOHANG,它表示“在有尚未终止的子进程在运行时不要阻塞”。这样,就可以用while + waitpid处理一系列的僵死进程,无遗漏。

注意,signal函数需要在accept函数前被调用。

当然,也还有其他解决办法...

1.2 处理SIGPIPE信号

当机器A(不一定是服务器)接收到机器B发来的一条FIN信号时,如果机器A再往机器B发数据,将会受到一条RST(复位)信号。

如果机器A在受到RST信号后,再往机器B发送数据,这时候就会收到SIGPIPE信号。

系统对SIGPIPE信号的默认处理就是,终止当前进程。(嘿嘿,这样服务器莫名其妙的就宕机了)

解决办法 捕获该信号,并忽略掉:signal(SIGPIPE, SIG_IGN);

1.3 处理EINTR错误

errno==EINTR该错误只会发生在慢系统中,所谓慢系统就是有阻塞的系统。比如read?accept?等,都是阻塞式函数,对应的系统也是慢系统。

慢系统(一部分),对阻塞式函数会意外的唤醒一次,避免处于永久阻塞。这时候就会得到EINTR错误。

举个例子,在read中如果获得EINTR错误,其实没数据到来,只是被意外唤醒了。

解决办法 捕获该错误,忽略它,并再次执行被唤醒的阻塞函数。

1.4 句柄的引用计数

下文的例子,采用了fork设计并发服务器。在子进程中close(listenfd),在父进程中close(connfd)。这是因为,创建子进程时会将父进程的状态拷贝一份,这时候socket句柄也就成了两份,此时引用计数就是2

一般情况下,close(fd)只是把引用计数-1。直到引用减到0时,才会真正的执行close关闭sokcet并发出FIN

1.5 以下是server.c源代码

#include <sys/socket.h> 
#include <netinet/in.h> //sockaddr_in
#include <unistd.h> //fork,read,write
#include <stdlib.h> //exit(0)
#include <errno.h>
#include <string.h> //bzero
#include <stdio.h>

const int SERV_PORT = 9527;
const int LISTENQ = 5;
const int MAXLINE = 128;

void sig_chld(int signo) {
pid_t pid;
int stat;

while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0 )
printf("child pid=%d terminated\n", pid);//警告: printf为不可重入函数
return; //显示`return`接受中断位置处的指令
}

void str_echo(int sockfd) {
ssize_t n;
char buf[MAXLINE];

for ( ; ; ) {
while ( (n = readline(sockfd, buf, MAXLINE)) > 0 )
writen(sockfd, buf, n); //DIY: write + while 参见下文
if (n < 0 && errno == EINTR)
continue;
break;
}
}

int main(int argc, char* argv[]) {
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;

listenfd = socket(AF_INET, SOCK_STREAM, 0);

bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

if ( bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0 )
exit_msg("bind error");

listen(listenfd, LISTENQ);

signal(SIGCHLD, &sig_chld);
signal(SIGPIPE, SIG_IGN);

for ( ; ; ) {
clilen = sizeof(cliaddr);
if ( (connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &clilen)) < 0 ) {
if (errno == EINTR)
continue;
exit_msg("accept error");
}
if ( (childpid = fork()) == 0 ) {//child fork
close(listenfd);
str_echo(connfd);
exit(0);
}
close(connfd);
}

return 0;
}

2 客户端

客户端主要流程:socket->connect。比服务端简单了很多,但是也有几点需要注意:

2.1 警告:connect不要捕获EINTR

如果捕获EINTR,再次connect。你将立即得到一个错误。

2.2 stdinFILE

Unix上,万物皆文件。将stdinfgets关联起来,很方便的从终端中读取输入内容。以下程序应该这样运行: ./client 127.0.0.1,附带一个参数—————服务器地址。

2.3 以下是client.c源代码

#include <sys/socket.h>
#include <netinet/in.h> //sockaddr_in
#include <arpa/inet.h> //inet_pon
#include <unistd.h> //fork,read,write
#include <string.h> //strlen
#include <stdlib.h> //exit(0)
#include <errno.h>
#include <stdio.h> //FILE*

void str_cli(FILE* fp, int sockfd) {
char send_line[MAXLINE], recv_line[MAXLINE];
while ( fgets(send_line, MAXLINE, fp) != NULL ) {
int n = writen(sockfd, send_line, strlen(send_line));
if ( readline(sockfd, recv_line, MAXLINE) == 0 )
exit_msg("str_cli readline==0");
fputs(recv_line, stdout);
}
}

int main(int argc, char* argv[]) {
int sockfd;
struct sockaddr_in servaddr;

if (argc != 2)
exit_msg("argv != 2");

sockfd = socket(AF_INET, SOCK_STREAM, 0);

bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

if (connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0)
exit_msg("conn err");//警告: connect请勿捕获EINTR

str_cli(stdin, sockfd);

return 0;
}

3 signal信号

函数声明是这样的,signal接受两个参数,一个为int,另一个为void (*func)(int)它接受一个int返回类型为void

void (*signal(int, void (*func)(int)))(int)

Stevens说信号函数中最好显示指明return即使是void func()函数,可以明确的回到中断位置,我不是很明白。

(另外,注意代码中的注释,这里是为了查看结果使用了printf,信号函数中一般不要使用这种不可重入函数)

void sig_chld(int signo) {
pid_t pid;
int stat;

while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0 )
printf("child pid=%d terminated\n", pid);//警告: printf为不可重入函数
return; //显示`return`接受中断位置处的指令
}

调用signal函数时,应该是这样signal(SIGCHLD, &sig_chld);。注意第二个参数,很多时候觉得函数名和对函数名取值有什么区别?如果打印出来,会发现它们的值是一样的,但是类型不一样。&sig_chld的类型是void (fun*)(int)

就如同int a[100];那么a&a[0]打印出来也一样,但是类型不一样。

4 TCP粘包问题

对于UDP来说,就没有该问题,因为它是一包、一包...发出去的。而TCP像流水一样,是没有隔断的。以下设计了一个以\n作为数据分隔符的TCP拆分程序。

很显然,这段程序是针对于文本内容的。

ssize_t readline(int fd, void* vptr, size_t max_length) {
ssize_t n, rc;
char c, *ptr;
ptr = vptr;

for (n = 1; n < max_length; ++n) {
if ( (rc = read(fd, &c, 1)) == 1 ) {
*ptr++ = c;
if (c == '\n')
break;
}
else if (rc == 0) {
*ptr = 0;
return (n-1);
}
else {
if (errno == EINTR) {
--n;
continue;
}
return -1;
}
}
*ptr = 0;
return n;
}

5 缓冲区达到极限问题

发送数据,就是往套接字上write的过程。但是,该过程可能会有一个问题。

举个例子,int n = write(fd, buf, 100);。假设我要求写入100个长度的数据包,结果返回值n=50。也就是说,实际上写入了50个长度的数据包,缓冲区就到达极限了。

解决办法 使用循环调用write,保证所有数据都发送了出去。

ssize_t writen(int fd, void const* vptr, size_t n) {
ssize_t nwritten;
size_t nleft = n;
char const* ptr = vptr;

while (nleft > 0) {
if ( (nwritten = write(fd, ptr, nleft)) <= 0 ) {
if (nwritten < 0 && errno == EINTR)
nwritten = 0;
else
return -1;
}
nleft -= nwritten;
ptr += nwritten;
}
return n;
}

References: [1] W.Richard Stevens,《Unix网络编程 卷1:套接字联网API(第3版)》 [2] 有心故我在,函数名 函数名取地址 区别