从零到负一

Socket编程基础2 - 基本回射服务实例

2020/04/20

这篇文章主要是用于复习UNP的第5章,这一章主要通过一个回射服务的实例来进一步讲解socket编程。这一章同时引入了多进程的内容,因此除了socket编程外,还需要懂一些Unix下的多进程编程。当然,Unix下的I/O操作也要熟悉,因为socket的读写以及从stdin获取数据需要使用Unix的I/O操作。

什么是回射服务?

回射服务就是客户端发送信息给服务器端,服务器端读取数据后又马上发送相同数据给客户端。图1是该服务的示意图:

图1:回射服务示意图

这个例子很简单,一般用于socket编程的入门,但其中还是有很多细节需要注意。下面将按照书的编排一步一步地搭建完整的回射服务。

第一版回射服务

服务器端代码

我们先来看看第一版的回射服务代码,下面是服务器端的主函数代码,代码中已经添加了注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// tcpcliserv/cpserv01.c
#include "unp.h"

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

// 建立socket,并bind, listen和accept
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);

Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);

for ( ; ; ) {
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
if ((childpid = Fork()) == 0) {
// 子进程处理客户端传输的数据
Close(listenfd);
str_echo(connfd);
exit(0);
}
Close(connfd);
// 父进程继续监听新的客户端
}
}

下面是str_echo()函数的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// lib/str_echo.c
#include "unp.h"

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

again:
// 收到数据就写回去
while ((n = read(sockfd, buf, MAXLINE)) > 0)
Writen(sockfd, buf, n);

// 如果读/写时候遇到中断,重新接收数据
if (n < 0 && errno == EINTR)
goto again;
else if (n < 0)
err_sys("str_echo: read error");
}

客户端代码

下面是客户端的主函数代码,代码中已经添加了注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// tcpcliserv/tcpcli01.c
#include "unp.h"

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

if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");

// 建立socket, connect到服务器端
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);

Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

// 通过stdin接收数据,发送到服务器端。然后通过stdout输出服务器
// 端发回的数据
str_cli(stdin, sockfd);
exit(0);
}

下面是str_cli的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// lib/str_cli.c
#include "unp.h"

void
str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];

// 等待stdin输入数据
while (Fgets(sendline, MAXLINE, fp) != NULL) {
// 将收到数据写入socket
Writen(sockfd, sendline, strlen(sendline));
// 接收服务器端发回的数据
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
}

正常启动的流程

正常启动后,会有3个进程被阻塞:

  1. 客户端进程被str_cli()中的Fgets()阻塞,等待stdin的输入;

  2. 服务器端子进程被str_echo()中的read()阻塞;

  3. 服务器端父进程进入第二次循环,被accept()函数阻塞。

直到我们从stdin输入数据,这3个进程都将一直阻塞下去。

正常终止的流程

如果客户端输入EOF,那么整个连接将终止。下面是这种情况下的终止流程:

  1. 当用户输入EOF时,str_cli()中的Fgets()跳出循环;

  2. 客户端主程序因为Fgets()的返回而结束,进程关闭,所有socket连接断开,进入FIN_WAIT_2状态;

  3. 客户端socket断开时,发送FIN给服务器端;

  4. 收到FIN后,服务器端str_echo()中的read()函数返回0,从而导致str_echo()的返回;

  5. str_echo()返回后,服务器端的子进程调用exit()进行关闭;

  6. 服务器端的子进程发送FIN给客户端,客户端返回ACK,至此客户端socket进入TIME_WAIT状态,并等待变成CLOSED状态;服务器端子进程socket从LAST_ACK状态变成CLOSED状态;

  7. 服务器端子进程结束时,会给父进程发送SIGCHLD信号,这里父进程并没有处理这个信号。

第二版回射服务

正如上面第7点说的,子进程结束时会发送SIGCHLD信号给父进程,但我们没有对这个信号进行处理。这么做的结果就是子进程会变成僵尸进程!为了解决这个问题,我们必须要让父进程捕获这个信号,因此第二版服务器端代码增加了相应代码,客户端不变。这里只是简单地介绍下多进程,多进程的内容会有其他文章详细介绍。

服务器端代码

主要改进是主函数,下面看看修改了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// tcpcliserv/cpserv01.c
#include "unp.h"

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

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);

Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);

// 新增了信号处理函数
Signal(SIGCHLD, sig_chld);

for ( ; ; ) {
clilen = sizeof(cliaddr);
if ((connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
// 如果阻塞在accept()时收到信号,我们不用退出,只需重新阻塞在accept()
if (errno == EINTR)
continue;
else
err_sys("accept error");
}

if ((childpid = Fork()) == 0) {
Close(listenfd);
str_echo(connfd);
exit(0);
}
Close(connfd);
}
}

下面看看Signal()函数。这个函数主要是用于注册信号,注册后的信号就可以被进程所捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// lib/signal.s
/* include signal */
#include "unp.h"

Sigfunc *
signal(int signo, Sigfunc *func)
{
struct sigaction act, oact;

act.sa_handler = func;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
if (signo == SIGALRM) {
#ifdef SA_INTERRUPT
act.sa_flags |= SA_INTERRUPT; /* SunOS 4.x */
#endif
} else {
#ifdef SA_RESTART
act.sa_flags |= SA_RESTART; /* SVR4, 44BSD */
#endif
}
if (sigaction(signo, &act, &oact) < 0)
return(SIG_ERR);
return(oact.sa_handler);
}
/* end signal */

Sigfunc *
Signal(int signo, Sigfunc *func) /* for our signal() function */
{
Sigfunc *sigfunc;

if ( (sigfunc = signal(signo, func)) == SIG_ERR)
err_sys("signal error");
return(sigfunc);
}

接下来我们看看处理SIGCHLD的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// tcpcliserv/sigchldwaitpid.c
#include "unp.h"

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

while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)
printf("child %d terminated\n", pid);
return;
}

这个函数用于释放子进程的资源,我们不用wait()而是用waitpid()是因为wait()只能用于第一个关掉的进程。如果有多个进程被关掉,那么会产生多个SIGCHLD信号,但Unix一般不支持信号的排队(信号也不能抢占正在执行的信号处理程序),因此用wait()只会释放一个进程的资源,其他进程还是僵尸进程!用waitpid()并使用-1作为参数,那么信号处理函数将处理该进程所有被关闭的子进程。

服务器正常/非正常关闭

前面我们讨论的都是客户端的正常关闭,这里我们讨论下服务器端的正常或者非正常关闭。

服务器正常关闭

服务器正常关闭就是通过Linux命令关闭服务器端的子进程,我们分析下发生了什么:

  1. 服务器端子进程发送FIN给客户端,子进程发送SIGCHLD给父进程;
  2. 客户端收到FIN后发送ACK给服务器端,但客户端现在被fgets()阻塞;
  3. 如果我们通过stdin给客户端输入数据,客户端还是会还是会发送给服务器端;
  4. 服务器端收到后返回RST,但客户端却很大可能收不到这个RST,因为它阻塞在Readline(),并且会先收到2中的FIN,因此返回0;
  5. 客户端随后返回错误退出。

上面这个流程是不正确的,原因在于客户端要么只能阻塞在了stdin,要么只能阻塞在Readline,这将导致程序错误的退出。正确的做法应该是让客户端可以阻塞于stdin或者Readline,这将是下章节改进的地方。

如果我们在Readline返回0后不理会,继续写入,那么第二次写入时会触发SIGPIPE。根据实际情况,我们可以忽视这个信号或者对其进行相应的处理。

服务器非正常关闭

服务器崩溃后,一般情况下,只要客户端不发送数据给服务器端,客户端是不知道服务器端崩溃了的。如果服务器崩溃后,客户端发送数据给服务器端,服务器端将返回RST,客户端收到后将返回错误。如果想要不发送数据也知道服务器是否崩溃了,我们需要使用其他技术。

处理数据格式

一般而言,我们收到数据都是需要特定格式的,我们可以用sscanf()snprintf()来进行格式化,这里简单介绍下这两个函数。

sscanf()

sscanf()函数原型如下:

1
int sscanf ( const char * s, const char * format, ...);

这个函数主要是将s指向的字符串,根据format来存储到相应的format中。比如:

1
2
3
4
// sentence ="Rudolph is 12 years old"
sscanf (sentence,"%s %*s %d",str,&i);
// str = Rudolph
// i = 12

sscanf()的格式很多,比较复杂,和正则表达式不一样。如果成功,函数返回有多少个format参数被赋值。

snprintf()

snprintf()函数原型如下:

1
int snprintf ( char * s, size_t n, const char * format, ... );

这个函数不是将字符串输出到stdout而是写入s中。n是写入的字符数,最多写n - 1个字符,最后一个字节会自动添加\0

CATALOG
  1. 1. 什么是回射服务?
  2. 2. 第一版回射服务
    1. 2.1. 服务器端代码
    2. 2.2. 客户端代码
    3. 2.3. 正常启动的流程
    4. 2.4. 正常终止的流程
  3. 3. 第二版回射服务
    1. 3.1. 服务器端代码
  4. 4. 服务器正常/非正常关闭
    1. 4.1. 服务器正常关闭
    2. 4.2. 服务器非正常关闭
  5. 5. 处理数据格式
    1. 5.1. sscanf()
    2. 5.2. snprintf()