Unix网络编程学习笔记之第5章 TCP客户端/服务器程序示例

一、 一个简单TCP回射服务端程序

#include "unp.h"
#define MAXLINE 1024
#define PORT 13
#define CONMAX 5
void err_sys(const char* s)
{
    fprintf(stderr, "%s\n",s);
    exit(1);
}
void str_echo(int connfd)
{
    int nbyte;
    char buff[MAXLINE+1];
again:
    while(nbyte=read(connfd,buff, MAXLINE)>0)
        write(connfd, buff, nbyte);
    if(nbyte<0&& errno=EINTR)//被中断,重启。后面详见
        goto again;
    else if(nbyte<0)
        err_sys("read error");
}
int main(int argc, char** argv)
{
    int listenfd, connfd;
    struct sockaddr_in servaddr;
    listenfd=socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr,sizeof(servaddr));
    servaddr.sin_family=AF_INET;
    servaddr.port=htona(PORT);
    servaddr.sin_addr.s_addr=htonl(INADDR_ANY);

    bind(listenfd,(struct sockaddr*) &servaddr, sizeof(servaddr));

    listen(listenfd, CONMAX);

    pid_t childpid;
    while(true)
    {
        connfd=accept(listenfd, (struct sockaddr*) NULL, NULL);
        if((childpid=fork())==0){
            close(listenfd);
            str_echo(connfd);
            exit(0);
        }
        close(connfd);
    }
}
// socket bind listen connect accept涉及socket的这些函数正常情况下都返回0或者具体描述符,在错误情况下都返回-1,错误被记录在errno。
//这里就不做错误检查了。

1. 这个回射程序很简单,就是客户端给服务器字符串,然后服务端接受并返回给客户端。和前面的程序相比较,不同的是这里是多进程程序,调用fork来创建子进程。子进程处理函数为str_echo

二、一个简单TCP回射客户端程序

#include "unp.h"
#define MAXCON 50
#define MAXLINE 1024
#define PORT 13
void err_sys(const char* s)
{
    fprintf(stderr, "%s\n",s);
    exit(1);
}
void str_cli(int sockfd, FILE* fd)
{
    char recvmsg[MAXLINE], sendmsg[MAXLINE];
    int n;
    while(fgets(sendmsg,MAXLINE,fd)!=NULL)//fgets函数会自动在尾部加'\0'
    {
        write(sockfd, sendmsg,strlen(sendmsg));
        if(read(sockfd,recvmsg, MAXLINE)<=0) //注意此时read返回0,不是正常终止,原因后面说
            err_sys("readerror");
        fputs(recvmsg, stdout);
    }
}
int main(int argc, char** argv)
{
    int sockfd;
    struct sockaddr_in servaddr;
    char buff[MAXLINE+1];
    if(argc!=2)
        err_sys("input error");
    if((sockfd=sock(AF_INET,SOCK_STREAM, 0))<0)
        err_sys("socket error");
    servaddr.sin_fimly=AF_INET;
    servaddr.sin_port=htons(PORT);
    if(inet_pton(AF_INET,argv[1], &servaddr.sin_addr)<=0)
        err_sys("ipaddress error");
    if(connect(sockfd,(struct sockaddr *)servaddr, sizeof(servaddr))<0)
        err_sys("connect error");
    str_cli(sockfd,stdin);
    exit(0);
}

1. 这个客户端程序很简单,就是从标准输入读入一行数据,然后发送给服务器。然后从服务器读取一行数据,显示在标准输出上。

三、正常启动

1. 首先服务器端启动,然后阻塞在accept函数。

2. 客户端启动,connect连接。然后客户端调用str_cli函数,阻塞在fgets函数上,等待用户输入数据。

3. 服务器端accept接受连接,fork一个子进程,调用str_echo函数,然后子进程阻塞在read函数上,而服务器端的主进程又会阻塞在accept函数,等待其他用户连接。

至此我们三个进程都在阻塞。

四、正常终止

1. 当客户端输入EOF(Ctrl-D)时,我们来终止程序,此时客户端fgets返回NULL,然后函数str_cli返回,则客户端exit退出,于是客户端会关闭打开的所有描述符,于是向服务器端发送一个FIN。

2. 当服务器端接受到这个FIN时,read函数返回0,向客户端发送ACK确认。然后 str_echo函数终止,然后子进程exit终止,这会关闭其打开的描述符,所以会向客户端发送一个FIN。

3. 然后客户端收到FIN并确认ACK,则至此客户端进入TIME_WAIT状态,服务器端继续监听其他客户。连接终止。

注意:Unix子进程终止时,会给父进程发送一个SIGCHLD信号,在上例中我们没有处理该信号,默认是忽略信号。既然父进程未加处理,则子进程进入僵死状态。

注意僵死状态说明该进程并没有完全地终止,没有释放所有资源。下面我们要说一下如何让子进程完全终止消失。

五、 信号处理

1. 信号:就是告知某个进程发生了某个事件的通知。一般信号都是异步的,即进程事先不知道信号在何时发生。

2. 信号可以是:

(1) 一个进程给另一个进程发送信号。

(2) 内核给一个进程发送信号。

上面提到的SIGCHLD信号就是内核在一个进程终止时,给它的父进程发送的信号。

3. 我们对一个信号的处理有三种方式:设置如何处理信号使用sigaction函数来设置。

(1) 我们提供一个信号处理函数,只要特定的信号发生,它就会被调用。这种行为称为捕获信号。有两种信号不能被捕获:SIGKILL和SIGSTOP

这个信号处理函数的原型:

void handler (int signo); 即无返回值,且参数是一个整型。

(2) 我们可以把某个信号的处理设定为SIG_IGN忽略它。SIG_KILL和SIG_STOP这两种信号不能被忽略。注意此时我们调用sigaction函数来设置忽略,则此进程就会被交给系统init去回收,不会存在僵死状态。

子进程进入僵死状态的原因是父进程未加任何处理。我们可以调用sigaction函数设置为SIG_IGN,则就不会产生僵死状态了。

(3) 我们也可以把某个信号的处理设定为SIG_DFL,来启动它的默认处置。有的信号默认处置是忽略,有的信号默认是终止进程。

4. sigaction函数

我们调用sigaction函数来设置对某个信号的处理。但是sigaction函数比较复杂,而signal函数比较简单:

void (* Sigfunc)(int);
Sigfunc signal(int sig, Sigfunc fun);

Sig为信号类型,如:SIGCHLD,fun为自定义的信号处理函数或SIG_IGN/SIG_DFL。然后函数返回该信号以前的信号处理函数指针。

但是POSIX规定要使用sigaction函数来设置信号。因为sigaction规定了一些有关信号处理的其他操作,而且signal函数在不同的系统之间的行为可能有所差别。有关如何使用sigaction详见P104,这里就不多说了。

5. POSIX保证某个信号在被处理函数处理期间,该信号是阻塞的。即如果此时,其他进程产生该信号,则该信号不会被提交。

注意:POSIX默认的是该信号在被处理函数处理处理期间,其他信号是不会被阻塞的,我们可以调用sigaction函数设置这个默认项。

且Unix信号是不排队的。即如果某个信号正在被处理,此时其他进程产生了多个该信号,则当这个信号被处理结束后,只有会产生一次信号提交。

六、 处理SIGCHLD信号

1. Unix设置僵死状态是为了维护子进程的信息,以便父进程在以后某个时候获取。这些信息包括进程pid,终止状态,资源利用信息等。

如果一个进程终止,如果该进程有僵死子进程,则僵死子进程的父进程的id都会被设为1,则交给init处理,来回收所有的僵死子进程。

注意只有fork子进程时,才有可能产生僵死进程。所以当我们fork子进程时,一定要处理SIGCHLD信号。

2. 在上述程序中,我们显然不需要保留僵死进程,则我们想要为SIGCHLD信号设置一个我们自定义的信号处理函数。则我们在主进程中加入代码:

signal(SIGCHLD, handler);   --注意这段代码一定要在第一个fork之前,且只要执行一次,所以在while循环之前加入此代码。

且必须在处理函数中调用wait/waitpid函数。

例如我们写一个处理函数:

void hander(int signo)
{
    pid_t pid;
    int stat;
    pid=wait(&stat);//此时的pid就是子进程的id
    return ;
}

3. 注意此时,看看程序,当一个子进程终止,向父进程递交SIGCHLD信号,此时父进程还正在阻塞accept。注意:此时父进程的accept会被中断,转而执行信号处理函数。即accept函数的优先级比较低。此时accept会发生错误,并且errno被设为EINTR。我们可以使用sigaction函数来设置处理该信号之后,会重启被中断的函数。

所以我们可以把上述的服务器端程序中的accept函数改为如下:

    while(true)
    {
        if((connfd=accept(listenfd,(struct sockaddr*) NULL, NULL))<0)
        {
            if(errno==EINTR)
                continue;
            else
                err_sys("accept error");
        }

注意此时accept可以重启的,read,write都是可以重启的。但是connect是不可以重启的,所以如果connect被中断,我们将调用select来等待连接完成。

七、 wait和waitpid函数

#include<sys/wait.h>
pid_t wait(int* statloc);
pid_t waitpid(pid_t pid, int* statloc, int option);

1. 可以看到waitpid提供了更多的选项,可以指定进程id以处理特定的子进程。-1表示处理第一个终止子进程。statloc和wait一样。option提供一些附加选项,其中最常用的就是WNOHANG,它告知waitpid在尚有未终止的子进程时,不要阻塞。

2. 我们建议使用waitpid,而不使用wait。原因:

考虑如下场景,当多个客户端连接上服务器端时,如果某个时刻,这多个客户端几乎同时终止。这样也就是多个子进程几乎同时终止,则同时给父进程发送多个SIGCHLD信号,而上文提到,Unix信号是不排队的,所以造成了不能完全处理多个子进程,导致存在多个僵死进程。

解决办法:使用waitpid

void hander(int signo)
{
       pid_t pid;
       int stat;
       while((pid=waitpid(-1,&stat, WNOHANG))>0);
       return;
}

此时我们使用WNOHANG来告知waitpid在尚有未终止的子进程时,不阻塞。而wait是阻塞的。

所以这样即使多个SIGCHLD信号几乎同时来到,waitpid不会阻塞,这样就能保证多个进程被正确处理,避免僵死进程的存在。

八、服务器进程终止

当我们正常的启动上述客户端和服务器端进程后,当某个时刻,服务器端的进程终止/崩溃了(可能是服务器端执行关机了,注意不是服务器主机崩溃)。

1. 这时,服务器端关闭打开的描述符,向客户端发送FIN。

2. 客户端响应ACK(注意一般响应ACK是内核自动完成的),但是此时我们看看客户端程序,其还阻塞在fgets上面,这就有问题了。客户端等待用户输入数据,当客户端输入数据后,阻塞在read上面,而服务器端接收到这个数据后发送一个RST,由于套接字接收缓冲区中已有一个第1步服务器发送FIN,所以read会去读这个FIN,然后导致read返回0,然后输出错误。导致程序终止。

3. 我们不希望出现这种情况,而是希望当服务器端程序终止,客户端这边应该立即知道,并且终止客户端。

4. 出现上述错误的原因在于,当FIN分组到达后,客户端阻塞在fgets上。客户此时有两个描述符,套接字和用户输入。它不能单纯的阻塞在某一个描述符上,所以后面我们提到使用select,poll等IO复用技术来解决这个问题。

插入的知识:

1. 发送/响应SYN,ACK,FIN,RST等分组是系统内核自动完成的,无需我们关心,即即使某个套接字描述符已经关闭,内核也可以发送RST,ACK等分组。

2. 套接字接收缓冲区的作用:当内核接收到对端发送的消息时,就会把这些消息放入套接字接收缓冲区中,如果此时进程正在处理其他程序,而不是阻塞在read上,等到进程处理完其他的程序后,调用read,这时read就会读出接收缓冲区内的消息。

九、 SIGPIPE信号

当一个进程向某个已收到RST分组的套接字描述符进行写操作时,内核就会向该进程发送SIGPIPE信号。

情景模拟:假如某个时刻服务器进程崩溃,客户端并不是知道,连续进行两次写操作,则第一次写操作,服务器端发送RST,第二次写操作就会发生SIGPIPE信号。

不管进程对该信号是处理还是忽略,第二个写操作都会返回EPIPE错误。

十、 服务器主机崩溃

注意这里不是主机关机。

则服务端不会发送任何东西,而客户端不知情,所以当客户端继续发送数据时,得不到任何东西,则它就会重传以希望得到ACK,大约过了9分钟左右,客户端才会发送重传。Read函数返回一个超时的错误。

十一、 服务器主机崩溃后重启

当服务器端崩溃后,9分钟内重启,此时服务器端接收到消息,而然服务器此时丢失了TCP连接的信息,所以它会向客户端发送RST分组。

客户端接收RST,然后read函数返回错误。

十二、服务器主机关机和服务器进程崩溃的效果是一样的。

十三、客户端、服务器端传送数据类型

我们可以在write函数中,发送二进制结构,如:结构体对象。

struct
{
       int i;
       long k;
}msg;
write(sockfd,&msg, sizeof(msg));

但是很不提倡在socket传输中,有这样的二进制结构,因为如果客户端和服务器端某一方不支持这种数据结构,或字节序不一样,支持的long的字节不一样,这都会引起异常的现象。

所以在传输socket时,应该把所有的二进制结构转换为字符型文本串,即char数组的形式。再进行传输是明智的。

时间: 2024-10-09 11:22:58

Unix网络编程学习笔记之第5章 TCP客户端/服务器程序示例的相关文章

Unix网络编程学习笔记之第2章 TCP和UDP

TCP 1. TCP面向连接的协议,是一个字节流协议,没有任何记录边界.发送的是数据分组. 2. TCP提供了可靠性:确认重传和重组 (1) TCP每发送一份数据都会要求对端进行确认.如果超时,就会重传.TCP会估计往返时间RTT,以确定等待多长时间重传. (2) 如果多次发送数据分组,TCP可以保证分组的按序达到.即会根据序列号进行重组. 3. TCP提供流量控制 TCP在任何时刻通知对端,它此时一次能够接受多少字节的数据,即通告窗口.该窗口指出接受缓冲区当前可用的空间. 4. 为何说TCP是

Unix网络编程学习笔记之第8章 基于UDP套接字编程

一. UDP C/S的典型函数调用 UDP没有像TCP那样的连接,客户端直接sendto向某服务器发送数据,服务器端一直recvfrom阻塞,以接收任何客户端发送的数据. 二. sendto和recvfrom函数 int sendto(int sockfd, const void* buff, size_t nbytes, int flag, const struct sockaddr* to, socklen_taddrlen); int recvfrom(int sockfd, void*

Unix网络编程学习笔记之第11章 名字与地址转换

一. 域名系统(DNS) 1. 简介 DNS主要用于主机名和IP地址之间的映射. 主机名可以是简单的名字ljm,也可以是全限定域名ljm.localdomainbaidu.com等. 2.资源记录 DNS中的条目称为资源记录(RR).我们感兴趣的RR类型只有几个: A             A记录把一个主机名映射为一个32位的IPv4地址. AAAA    4A记录把一个主机名映射为一个128位的IPv6地址. 例如: ljm               IN      A    127.0.

Unix网络编程学习笔记之第7章 套接字选项

一.获取/设置套接字选项的方法 一个套接字描述符相关联的套接字选项很多.获取/设置套接字选项的方法: 1.  getsockopt和setsockopt函数 2. fcntl函数 3. ioctl函数 二. getsockopt和setsockopt函数 int getsockopt(int sockfd, int level, int optname, void* optval, socklen_t* optlen); int setsockopt(int sockfd, int level,

Unix网络编程学习笔记之第6章 I/O复用:select和poll函数

一.I/O复用应用场合 1. 当客户处理多个描述符(既有标准输入,又有网络套接字)时,必须使用IO复用. 2. 一个客户同时处理多个套接字是可能的. 3. 如果一个服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用. 4. 如果一个服务器既要处理TCP,又要处理UDP,一般就要I/O复用. 5. 如果一个服务器要处理多个服务或协议,就要用到I/O复用. 其实IO复用就是一个进程/线程处理多个套接字描述符. 二. I/O模型 Unix提供了5种I/O模型: 1. 阻塞式I/O模

Unix网络编程学习笔记之第1章 简介

一.一个简单的时间获取客户端 #include <sys/socket.h> #define MAXCON 50 #define MAXLINE 1024 #define PORT 13 void err_sys(const char* s) { fprintf(stderr, "%s\n",s); exit(1); } int main(int argc, char** argv) { int sockfd; structsockaddr_in servaddr; cha

Unix网络编程学习笔记之第12章 IPv4与IPv6的互操作性

一. 简介 假设我们本章讨论的主机都是支持双栈的,即支持IPv4地址,也支持Ipv6地址. 我们本次讨论的点:客户端与服务器端使用的是不同类型的地址.因为相同类型的地址没什么可讲的. 二. IPv4客户端与IPv6服务器 即,客户端使用IPv4地址套接字来通信,服务器端使用IPv6地址套接字通信. 原理: 0. 首先IPv6服务器主机保证既有IPv4地址,又有IPv6地址. 1. IPv4客户端通过getaddrinfo函数,找到服务器端的IPv4地址,然后进行连接. 2. 来自客户端的IPv4

Unix网络编程学习笔记之第4章 基于TCP套接字编程

1. socket函数 int socket(int family, int type,int protocol) 成返回一个套接字描述符.错误返回-1 其中family指定协议族,一般IPv4为AF_INET, IPv6为AF_INET6. 其中type指定套接字类型,字节流:SOCK_STREAM.   数据报:SOCK_DGRAM. 一般情况下通过family和type的组合都可以唯一确定一个套接字类型.所以一般我们就把protocol设为0就可以了. 有时在某些特殊情况下,family和

UNIX网络编程学习笔记2 需要用到的一些字节操纵和格式转换函数

当然这些东西是炒鸡无聊的,但是真当自己开始撸代码时才发现熟悉这些枯燥的函数能够节约大量的时间.于是总结一下: 字节序:低序字节存储在起始地址,这称为小端(little-endian),高序字节存储在起始地址,这称为大端(big-endian) 例:存放0x0A0B0C0D LE: 0D 0C 0B 0A BE: 0A 0B 0C 0D 小端的存放方式更加接近于人类思维 网际协议使用大端字节序来传送多字节整数(为何要规定一个字节序来传输ip和port? 呸 这样子协议才能正确“看懂”(解释)这些地