本案例将用多进程实现一个基于Linux使用C++实现的C/S网络程序:客户端从终端输入,然后借助服务端回显。进而观察TCP的状态转换图,思考多进程网络编程存在的问题。
1.服务端程序(Linux)
服务进程:通过监听所有网卡的9877接口,当有客户端来连接时,使用fork创建一个子进程对客户端连接进行服务,然后父进程继续监听连接的到来。需要注意的是当父进程未退出时,子进程在结束后将进入僵尸状态。父进程未使用信号对这些僵尸进程进行处理,随着连接的增多,服务端将出现很多僵尸进程。当然,如果父进程退出,则其僵尸子进程将被过继给init进程(进程号为1),而init进程干的事情就是不断回收这些僵尸进程,系统将很快恢复正常。
本代码还存在一个问题,就是当大量并发连接来临时,将创建一个子进程对客户端进行一一回复,这样创建的进程数将很快到达系统的极限,同时创建一个进程将是很耗资源的,服务端很快就会奔溃。
tcpserv01.c :
#include <netinet/in.h> // for htonl htons
#include <sys/socket.h> // for socket bind listen accept
#include <strings.h> // for bzero
#include <unistd.h> // for close fork and so on
#include <stdlib.h> // for exit
#include <errno.h> // for errno
#include <stdio.h>
#include <string.h>
#define SERV_PORT 9877
#define LISTENQ 1024
#define MAXLINE 4096
// 定义通用的socket address
typedef struct sockaddr SA;
void str_echo(int sockfd);
ssize_t writen(int fd, const void *vptr, size_t n);
void print_error(const char* err);
int main(int argc, char** argv){
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
// IPv4地址结构
struct sockaddr_in cliaddr, servaddr;
// 使用IPv4和流协议
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(listenfd < 0){
print_error("socket fail");
}
// 在初始化socket address数据结构之前,将其清零
bzero(&servaddr, sizeof(servaddr));
// IPv4 : 指定使用IPv4地址家族
servaddr.sin_family = AF_INET;
// 设置任何接口的IPv4地址,这里将32bit的主机整数转换为网络字节序
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
// 设置监听的端口地址
servaddr.sin_port = htons(SERV_PORT);
// 绑定监听的地址
int ret = bind(listenfd, (SA*) &servaddr, sizeof(servaddr));
if(ret < 0){
print_error("bind fail");
}
// 进入监听状态,服务进程
listen(listenfd, LISTENQ);
while(1){
clilen = sizeof(cliaddr);
// 阻塞,直到有连接到达为止,且可以获取客户端的连接地址,value-result
connfd = accept(listenfd, (SA*) &cliaddr, &clilen);
if((childpid = fork()) == 0){
// 子进程:关闭共享的监听句柄
close(listenfd);
// 进行具体的操作
str_echo(connfd);
// 结束子进程,同时将会自动关闭所有打开的文件句柄
exit(0);
}
// 父进程:关闭打开的连接句柄,然后继续接受连接
close(connfd);
}
exit(0);
}
// 保证一次能写n个字节,同时处理中断重入的情况
ssize_t writen(int fd, const void* vptr, size_t n){
size_t nleft;
ssize_t nwritten;
const char* ptr;
ptr = vptr;
nleft = n;
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;
}
// 子进程处理的主函数,不断地把读到的字节写回去,直到读到的字节数为0或者出错
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;
}
if(n < 0){
print_error("str_echo");
}
}
// 获取错误号对应的内容,输出错误信息,并退出
void print_error(const char* err){
int errno_save = errno;
printf("%s : %s\n", err, strerror(errno_save));
exit(1);
}
2.客户端程序(Linux)
客户端:从终端不断读取输入,然后发给服务端,最后客户端再从服务端读取回来,并在终端展示。
tcpcli01.c :
#include <netinet/in.h>
#include <strings.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <stdio.h>
#define SERV_PORT 9877
#define MAXLINE 1024
typedef struct sockaddr SA;
void print_error(const char* err);
void str_cli(FILE* fp, int sockfd);
ssize_t writen(int fd, const void* vptr, size_t n);
ssize_t readline(int fd, void *vptr, size_t maxlen);
ssize_t my_read(int fd, char* ptr);
int main(int argc, char** argv){
int sockfd;
struct sockaddr_in servaddr;
// 带一个参数作为服务端的IPv4地址
if(argc != 2){
printf("format : %s IPv4\n", argv[0]);
exit(1);
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0){
print_error("socket error");
}
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
// 将输入的点分十进制IPv4地址转换为网络字节地址
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
int ret = connect(sockfd, (SA*)&servaddr, sizeof(servaddr));
if(ret < 0){
print_error("connect fail");
}
// 客户端进程的主要方法
str_cli(stdin, sockfd);
exit(0);
}
void print_error(const char* err){
int errno_save = errno;
printf("%s : %s\n", err, strerror(errno_save));
exit(1);
}
// 保证一次能写入n个字节,同时处理中断重入的情况
ssize_t writen(int fd, const void* vptr, size_t n){
size_t nleft;
ssize_t nwritten;
const char* ptr;
ptr = vptr;
nleft = n;
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;
}
// 客户端逻辑的主要函数:从终端不断读取输入,然后发给服务端,最后客户端再从服务端读取回来,并在终端展示
void str_cli(FILE* fp, int sockfd){
char sendline[MAXLINE], recvline[MAXLINE];
while(fgets(sendline, MAXLINE, fp) != NULL && sendline[0]){
writen(sockfd, sendline, strlen(sendline));
if(readline(sockfd, recvline, sizeof(recvline)) == 0){
print_error("server call close first");
}
// 已经添加了0
fputs(recvline, stdout);
}
}
// 借助缓冲区减少使用read的次数,每次读取一个字符
ssize_t my_read(int fd, char* ptr){
// 静态变量,保证一直都存在
static int read_cnt;
static char* read_ptr;
static char read_buf[MAXLINE];
// 如果能读的字符已经读完,则重新读取
if(read_cnt <= 0){
again:
if((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0){
if(errno == EINTR){
goto again;
}
return -1;
}
else if(read_cnt == 0){
return 0;
}
read_ptr = read_buf;
}
// 读取一个字符,同时移动缓冲区的指针read_ptr
read_cnt--;
*ptr = *read_ptr++;
return 1;
}
// 通过检查换行符读取一行,同时会添加0,这是C风格的字符串。如果没找到换行符,则以maxlen - 1个字符返回,同时包含一个0
ssize_t readline(int fd, void *vptr, size_t maxlen){
ssize_t n, rc;
char c, *ptr;
ptr = vptr;
// 读取maxlen个字符,判断是否出现换行符,留出一个空间填零
for(n = 1; n < maxlen; n++){
if((rc = my_read(fd, &c)) == 1){
*ptr++ = c;
if(c == '\n'){
n++;
break;
}
}
else if(rc == 0){
*ptr = 0;
// 没有读取到字符,服务端已经结束, EOF
return n - 1;
}
else{
return -1;
}
}
// 读取到换行,或者读满maxlen - 1个字符,最后一个字符自己添加,且是0
*ptr = 0;
return n - 1;
}
3.测试
3.1.获取IP地址
运行环境是Ubuntu15.04。首先,通过运行:ifconfig,获得服务端的IP地址
显然轮回网卡lo的IP地址是127.0.0.1,wlan0的IP地址是192.168.1.100。
3.2.运行服务端程序
编译程序:
gcc -o tcpserv01 tcpserv01.c
gcc -o tcpcli01 tcpcli01.c
以后台运行的形式启动服务端:
./tcpserv01 &
然后查看进程的状态 :
netstat -a
从中可以看到服务进程的进程号为27209,已经在9877处于监听状态。*:9877中*表示任意接口地址,是上面设置了INADDR_ANY的结果。
同时,可以在运行服务端的终端下运行 : tty
从而获取到服务端运行的伪终端:
3.3.运行客户端程序
这里在跟服务端同一台机器(Ubuntu)上运行两个客户端程序:
./tcpcli01 192.168.1.100
./tcpcli01 127.0.0.1
3.4.获取进程启动时的相关信息和网络状态
netstat可以查看socket的网络连接状态。在另一个终端运行 :
netstat -a | grep 9877
ps -t /dev/pts/1 -o pid,ppid,tty,stat,args,wchan
注意:使用ps aux | grep tcp可以获得进程对应的伪终端。因为每个进程都有一个执行命令,而我们的执行命令中含有tcp这个子串。
可以看到出现五个进程:
服务端进程:
父服务进程处于LISTEN状态 ,pid = 27209
子服务进程192.168.1.100,pid = 31715, ppid = 27209
子服务进程127.0.0.1,pid = 32133, ppid = 27209,子进程同时拥有与父进程相同的伪终端pts/7
客户端进程:
192.168.1.100, pid = 31714, port number = 51177
127.0.0.1, pid = 32132, port number = 33189
刚好可以从第一幅图看到5条网络连接状态。此时其他两条网络连接已经创建(established),两个子服务进程处于休眠状态(Sleep),阻塞。而两个客户端进程在等待用户输入,也处于休眠状态。
3.5.主动结束客户端
当我在客户终端192.168.1.100输入数据时,将出现回显,最后按下Ctrl + D:
这时输入结束,客户端进程将退出,从而发起四次挥手,结束数据交换和连接。最后客户进程exit终止,但主动结束连接者将进入TIME_WAIT状态,并且会停留2MSL的时间,这是客户进程无法看到的:
3.6.子服务进程进入僵尸状态
同时对应的子服务进程是被动关闭的一方,在收到最后一个ACK后连接就终止了。但是子服务进程却进入僵尸状态:
上述PID = 31715的子服务进程的状态是Z,即zombie,僵尸状态,进程表项一直没被回收。
3.7.终止父服务进程
终止了父服务进程,所有僵尸进程将被过继给init进程,被回收。
上图显示两个子服务进程都变成僵尸进程,然后使用fg使得后台进程变成前台进程,同时按下Ctrl + C终止父服务进程,之后原来的僵尸进程都消失了。