TCP IP网络编程学习笔记

基本数据结构和函数 表示IPv4地址的结构体:

struct sockaddr_in { sa_family_t sin_family; //表示地址族 uint16_t sin_port; //表示16位的端口号(包括TCP和UDP) struct in_addr sin_addr; //32位的ip地址 char sin_zer[8] ; //不使用}; 对于in_addr:struct in_addr{ In_addr_t s_addr; //32位IP地址 }

对于上面定义的成员变量类型,大部分都是在POSIX里面定义好了的,属于跨平台可移植的基本数据类型的别名。
对于sockaddr_in成员变量的分析:
  • sin_family: 对于每种协议适用于不同的地址族。典型的有:
    • AF_INET : IPv4协议使用的地址族
    • AF_INET6 : IPv6协议使用的地址族
    • AF_LOCAL : 本地通信
  • sin_port: 以网络字节序列保存端口号
  • sin_zero: 字节填充符,无特殊的含义。
对于socket进程需要绑定socket地址。对应的函数是bind
具体用法:
bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr));

这里需要注意的是struct sockaddr*这个数据结构。在原先老版本的unix网络通信中并没有sockaddr_in这个数据结构,只有sockaddr
struct sockaddr { sa_family_t sin_family; char sa_data[14]; //地址信息 }

注意这里的sa_data数组包含了sockaddr_in数据结构中后面的所有信息,包括端口号、IP地址等等,这样的后果是编程起来非常不方便。所以后面把这个数据结构转换为了sockaddr_in,但同时也保证了字节序列和这个数据结构一致。因此这里即便使用了强制类型转换也不会影响数据的含义。
网络字节序列 由于不同的机器CPU采用的存储策略也不同。有些采用大端法,有些采用小端法。为了保持在不同机器之间进行网络通信数据格式的统一,网络序列统一采用大端法。所以在小端法的机器上发送数据时,首先要转换为大端法。对应的系统API:
unsigned short htons(unsigned short); //htons:host to network , short number unsigned long htonl(unsigned long); //host to net , long number unsigned short ntohs(unsigned short); //net to host, short number …………

网络地址初始化 对于IP地址的表示,由于我们大部分时候是以点分十进制表示法来理解的。但是计算机保存的是二进制序列,所以在初始化真正的IP地址时,我们需要能够在点分十进制和二进制之间互相转换的API
#include in_addr_t inet_addr(const char* string); //成功时返回32位的大端法整数值,失败返回INADDR_NONEint inet_aton(const char *string, struct in_addr * addr); //与上述的API不同点在于,将返回的IP地址直接保存到指针内部 //失败了返回0,成功了返回1char* inet_ntoa(struct in_addr addr); //相反的功能

结合上述的所有内容,一般情况下unix的网络链接初始化的代码如下:
struct sockaddr_in addr; //新建一个网络地址信息对象char * serv_ip= "211.217.168.13"; //输入已知的点分十进制IP地址 char * serv_port = "9190" ; //端口号 memest(&addr, 0, sizeof(addr)); //初始化 addr.sin_family = AF_INET; //IPv4的协议地址族 addr.sin_addr.s_addr = inet_addr(serv_ip); //初始化字符IP addr.sin_port = htons(atoi(serv_port)); //初始化字符port。也可以是数字

对于服务端的IP地址,可以使用一个随机初始化变量:INADDR_ANY。一般服务器有限考虑这种形式。
最后把初始化信息分配给套接字:
#include int bind(int sockfd, struct sockaddr* myaddr,socklen_t addrlen);

实现基于TCP的服务端 TCP 服务端默认的函数调用顺序
socket(); //创建套接字 | bind(); //分配套接字地址 | listen(); //监听客户端的请求 | accept(); //允许连接 | read()/write(); //数据交换 | close(); //关闭连接

等待连接请求状态 在服务期端的表现为listen函数
#include int listen(int sock//the socket in server , int backlog); //the size of waiting queue

只有当服务器端处于监听状态才能接受客户端的connect请求,否则会报错。
受理客户端的连接请求 之后要对处于监听队列的客户端请求作出受理。需要使用accept函数
#include int accept(int sock,// the socket in server struct sockaddr * client_addr,//客户端的socket地址详细信息 socketlen_t * addrlen); //上面的变量的长度

hello_server服务端实例
#include #include #include #include #include #include void error_handling(char *message); int main(int argc,char *argv[]){ int serv_sock; int clnt_sock; struct sockaddr_in serv_addr; struct sockaddr_in clnt_addr; socklen_t clnt_addr_size; char msg[]= "Hello World!"; if(argc != 2){ printf("Usage: %s \n",argv[0]); exit(1); } serv_sock = socket(PF_INET,SOCK_STREAM,0); //create a TCP socketif(serv_sock == -1){ error_handling("socket() error"); }memset(&serv_addr,0,sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); serv_addr.sin_port = htons(atoi(argv[1])); if(bind(serv_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr)) == -1){ error_handling("bind() error"); }if(listen(serv_sock,5) == -1){ error_handling("listen() error"); }clnt_addr_size = sizeof(clnt_addr); clnt_sock = accept(serv_sock, (struct sockaddr*) &clnt_addr, &clnt_addr_size); if(clnt_sock == -1){ error_handling("accept() error"); }write(clnt_sock,msg,sizeof(msg)); close(clnt_sock); close(serv_sock); return 0; }void error_handing(char* message){ fputs(message, stderr); fputc('\n',stderr); exit(-1); }

实现基于TCP的客户端 客户端默认的函数调用顺序
socket(); //创建套接字 | connect(); //请求连接 | read()/write(); //交换数据 | close(); //关闭连接

请求连接
#includeint connect(int sock,//客户端的套接字文件描述符 struct sockaddr* servaddr,//目标服务器的地址信息变量地址 socklen_t addrlen);

客户端调用这个请求的时候,只有当服务器端接受了连接或者产生异常中断的时候才会得到返回结果。
hello_client客户端实例
#include #include #include #include #include #include void error_handling(char *message); int main(int argc,char **argv){ int sock; struct sockaddr_in serv_addr; char msg[30]; int str_len; if(argc != 3){ printf("Usage : %s \n",argv[0]); exit(1); }sock = socket(PF_INET,SOCK_STREAM,0); if(sock == -1){ error_handling("socket() error"); }memset(&serv_addr,0,sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = inet_addr(argv[1]); serv_addr.sin_port = htons(atoi(argv[2])); if(connect(sock,(struct sockaddr*) &serv_addr,sizeof(serv_addr)) == -1){ error_handling("connect() error"); }str_len = read(sock,msg,sizeof(msg)-1); if(str_len == -1){ error_handling("read() error"); }printf("Message from server : %s\n",msg); close(sock); return 0; }void error_handling(char* message){ fputs(message,stderr); fputc('\n',stderr); exit(1); }

TCP套接字的缓冲 server端的输出端就是client端的输入端。同时,每个端口都配有缓冲区,双方进行通行的时候,首先把数据放到缓冲区上,然后再从缓冲区进行IO:read就是把对应的字符流加入到缓冲区,write就是把对应的字符流从缓冲中取出
基于UDP的服务端和客户端 UDP与TCP的最主要区别: TCP存在流控制,而UDP则不存在流控制
同时在编程的时候,实际不存在服务端和客户端的概念,只有发送端和和接收端。因为TCP连接中,服务端有很多的socket进程,每一个socket对应一个客户端socket。而UDP连接中不存在这样的东西,哪怕是服务端也只有一个UDP套接字。所以实际上在UDP连接中,客户端和服务端是处于对等的状态
常用API
#include ssize_t sendto(int sock,//用于传输数据的套接字文件描述符 void *buff,//带传输的数据地址 size_t nbytes,//需要传输的字节大小int flags,//选项参数 struct sockaddr* to,//目标地址的详细信息 socklen_t addrlen); //传递给参数to的地址值结构体变量长度ssize_t recvfrom(int sock,//用于传输数据的套接字文件描述符 void *buff,//带接受数据的地址 size_t nbytes,//需要接受的字节大小int flags,//选项参数 struct sockaddr* from,//源地址的详细信息 socklen_t addrlen); //传递给参数from的地址值结构体变量长度

IP地址和端口分配 我们都知道,
断开连接 以上所有代码的断开连接我们都是使用了close。但是我们之前也知道,一个socket同时拥有输入流和输出流,如果直接close,就会把输入输出流同时关掉。考虑这样的一个场景,服务器要发送大量的数据给客户端,而客户端给服务端响应的内容只有几个字节。由于客户端收到数据后还有进行一系列的操作,如果只为了等那么点信息而一直保持socket打开显然是不划算的。所以当我们把数据发送完之后,可以选择关闭服务端的输出流,同事保持输入流打开,这样就节省了一定的资源,又能够保持和客户端的通信。
API
int shutdown(int sock, //套接字的文件描述符 int howto)//如何关闭,可以全关,可以只关输入/输出流

域名系统编程 利用域名获取IP地址 主要用到的API:
#include struct hostent* gethostbyname(const char* hostname); struct hostent { char * h_name; //官方域名 char ** h_aliases; //通过多个域名能够访问同一IP,这里保存对应的别名 int h_addrtype; //IPv4 or IPv6 or else int h_length; //IP地址长度 char** h_addr_list; //对应的IP地址列表}

后面的IP地址列表的变量类型是char**,但实际上指向的是in_addr结构体变量的地址。所以在获得这个成员属性的时候还要做适当的转换。
inet_ntoa(*(struct in_addr*)web_info->h_addr_list[i])

这样就能够得到字符串形式的主机形式IP地址
利用IP地址获取域名 主要API:
struct hostent* gethostbyaddr(const char* addr,//含有IP信息的in_addr结构体指针 socklen_t len,//IPv4的时候为4,IPv6为16 int family); //传递地址族信息

进程相关API 僵尸进程 正常情况下,当父进程生成子进程的时候,期待接收子进程调用的返回结果。
如果父进程收到了这个结果,那么就可以主动销毁子进程;而如果没有,那么就必须让子进程一直不停的在运行,直到父进程自己的工作全部结束。
但即使子进程调用return或者exit函数传递返回值,这些返回值并不会被父进程接收,它们首先被操作系统接收。之后当父进程调用某些特定的函数后才能够从操作系统中获取这些信息进而销毁子进程
销毁僵尸进程的方式
利用wait函数
#include pid_t wait(int * statloc); //将子进程的返回值放入到statloc指针当中。/* 但是返回的内容中还有其他的东西,需要进行宏分离 */WIFEXITED(status):子进程正常终止时返回true WEXITSTATUS(status):返回子进程的返回值

利用waitpid函数
pid_t waitpid(pid_t pid,//等待被终止的子进程ID,如果为-1,则可以是任意子进程 int* statloc, //与wait函数含义一致 int options); //若传递WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出成功时返回终止子进程的ID或者0,失败返回-1

demo:
#include"common.h" int main(int argc, char **argv){ int status; pid_t pid = fork(); if(pid == 0){ sleep(15); exit(24); } else{ while (!waitpid(pid,&status,WNOHANG)) { sleep(3); printf("the parent process sleep for 3 seconds\n"); } if(WIFEXITED(status)){ printf("the child process finished\n,return value is: %d\n",WEXITSTATUS(status)); }} return 0; }

TCP IP网络编程学习笔记
文章图片

waitpid相比于上面的wait函数,好处在于:
  1. 能够针对某一个特定的子进程进行销毁操作,而不是像wait函数一样根据时间的先后顺序。灵活性更强
  2. 在options参数里面可以设置为非阻塞模式,在某些场景下会更加适合
signal信号处理 信号处理是在特定的事件发生之后,操作系统向进程发送消息,为了响应这个消息,执行与消息相关的过程就被称为“处理”或者“信号处理”
signal函数
#includevoid (*signal (int signo, //信号的类型 void (*function)(int))) //处理信号的函数指针(可以理解为handler) (int); //由于返回的是函数指针,所以需要返回给参数为int的某个函数

第一个参数常用的值:
  • SIGALRM: 已经通过调用alarm函数注册的时间,也就是说alarm正式生效,发出alarm信号
  • SIGINT:通过Ctrl+C按钮终止
  • SIGCHILD:子进程终止
alarm函数:
#include unsigned int alarm(unsigned int seconds); 返回0或者以秒为单位的距离SIGALRM信号发生所剩时间

demo:
#include"common.h" void timeout(int sig){ if(sig == SIGALRM){ puts("time out"); } alarm(2); }void keycontrol(int sig){ if(sig == SIGINT){ puts("Ctrl + C pressed"); } }int main(int argc,char **argv){ int i; signal(SIGINT,&keycontrol); signal(SIGALRM,&timeout); alarm(2); for(i =0 ; i<1000; i++){ puts("wait...."); sleep(109); } return 0; }

sigaction函数
之前所用的signal函数目前使用的比较少了,大部分时候我们都会用sigaction这个函数来进行信号处理。sigaction函数的优势在于它在不同的UNIX操作系统中基本上一致。
API:
#include int sigaction(int signo,//信号的信息 const struct sigaction * act,//对应于第一个参数的信号处理动作 struct sigaction* oldact); //获取之前注册的信号处理指针,若不需要置为0struct sigaction{ void (*sa_handler)(int); //保存信号处理函数的指针值 sigset_t sa_mask; //使用sigemptyset(addr)进行初始化 int sa_flags; //0 }

利用信号处理函数关闭僵尸进程
原理: 利用子进程使用系统调用exit 或者 return时,在操作系统中产生子进程结束信号。这个时候只需要我们写一个处理器,专门用于处理这种僵尸进程,然后把这个处理器和该信号绑定即可。这样,每当产生一个子进程结束信号的时候,就会自动调用read_childproc函数。
#include"common.h"//handle the zombie process void read_childproc(int pid){ int status; //because we randomly kill zombie process,"id" is to get the kiled pid pid_t id = waitpid(-1,&status,WNOHANG); if(WIFEXITED(status)){ printf("Removed proc id: %d\n",id); printf("Child send: %d\n",WEXITSTATUS(status)); } }int main(int argc,char **argv){ pid_t pid; struct sigaction act; act.sa_handler = read_childproc; sigemptyset(&act.sa_mask); act.sa_flags = 0; //whenever OS produce a child process end signal, out program will call the handler function sigaction(SIGCHLD, &act,0); pid = fork(); if(pid == 0){ puts("Hi! I'm chlild process\n"); sleep(10); return 12; } else{ printf("Child proc id: %d \n",pid); pid = fork(); if(pid == 0){ puts("Hi! I'm child process"); sleep(10); return 24; } else{ int i ; printf("Chlid proc id: %d\n",pid); for(int i = 0; i<5; i++){ puts("wait.."); sleep(5); } } } return 0; }

多进程服务器 demo:
#include"common.h" int main(int argc,char **argv){ int serv_sock, clnt_sock; struct sockaddr_in serv_adr, clnt_adr; pid_t pid; struct sigaction act; socklen_t adr_sz; int str_len, state; char buf[BUF_SIZE]; if(argc != 2){ printf( "Usage : %s \n",argv[0] ); exit(1); } act.sa_handler = read_childproc; serv_sock = socket(PF_INET,SOCK_STREAM,0); sigemptyset(&act.sa_mask); act.sa_flags = 0; state = sigaction(SIGCHLD,&act,0); memset(&serv_adr,0,sizeof(serv_adr)); serv_adr.sin_family = AF_INET; serv_adr.sin_addr.s_addr = htonl(INADDR_ANY); serv_adr.sin_port = htons(atoi(argv[1])); if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1){ error_handling("bind() error"); }if(listen(serv_sock,5) == -1){ error_handling("listen() error"); }while (1) { adr_sz = sizeof(clnt_adr); clnt_sock = accept(serv_sock,(struct sockaddr*) &clnt_adr, &adr_sz); if(clnt_sock == -1){ continue; } puts("new client connected...."); pid = fork(); if(pid == 0){ close(serv_sock); while ((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0) { write(clnt_sock,buf,str_len); } close(clnt_sock); puts("connection lost.."); return 0; } else { close(clnt_sock); } } close(serv_sock); return 0; }

需要注意的是,子进程拷贝父进程的内存,但是文件描述符存在于操作系统中的一张表中,复制的是引用,实际上二者指向的是同一个套接字对应的硬件资源。而套接字必须要等所有指向它的文件描述符全部关闭才会被销毁,因此在子进程中必须每次都释放服务器的套接字文件描述符
利用进程对客户端I/O分离 之前客户端程序的反复read/write实际上浪费了很多的时间,read操作必须要等到服务器端将对应的数据写入到socket中才能够实现。而实际上我们可以把IO分开,在write的同时就让read操作进入到忙等待状态,这样当处于低网速状态的时候能够非常显著地提升效率
#include"common.h"void read_routine(int sock, char* buf); void write_routine(int sock, char* buf); int main(int argc ,char **argv){ int sock; struct sockaddr_in serv_addr; char message[BUF_SIZE]; int str_len,recv_len,//用于统计当前的接受数据长度 recv_cnt; //统计每一次接收数据的长度if(argc != 3){ printf("Usage : %s \n",argv[0]); exit(1); }sock = socket(PF_INET,SOCK_STREAM,0); if(sock == -1){ error_handling("socket() error"); }memset(&serv_addr,0,sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = inet_addr(argv[1]); serv_addr.sin_port = htons(atoi(argv[2])); if(connect(sock,(struct sockaddr*) &serv_addr,sizeof(serv_addr)) == -1){ error_handling("connect() error"); } puts("Connected....."); pid_t pid = fork(); if(pid == 0){ write_routine(sock,message); } else{ read_routine(sock,message); }close(sock); return 0; }void read_routine(int sock,char *buf){ while(1){ int str_len = read(sock,buf,BUF_SIZE); if(str_len == 0) return; buf[str_len] = 0; printf("Message from server: %s\n",buf); } }void write_routine(int sock,char *buf){ while(1){ fgets(buf,BUF_SIZE,stdin); if(!strcmp(buf,"q\n") || !strcmp(buf,"Q\n")){ shutdown(sock, SHUT_WR); return; } write(sock,buf,strlen(buf)); } }

I/O复用 背景 前面讲到的多进程服务端模型对于每一个来自客户端的请求,都会创建一个新的进程用来处理服务端的IO操作。而事实上,可以把不同套接字的IO操作集成到一个进程上来统一完成,这就是IO复用
select 函数
#include #includeint select(int maxfd,//最大的监听数量 fd_set* readset,//输入流的监听队列 fd_set* writeset,//输出流…… fd_set* exceptset,//异常流…… const struct timeval * timeout); //超时设置 /* fd_set的结构 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | .... fd0 fd1fd2 fd3 fd4....... 设置为1表示对于该文件描符进行监听 */

多线程 线程创建和执行
#include int pthread_create( pthread_t * restrict thread,//保存线程的ID const pthread_attr_t * restrict attr,//默认为NULL,高阶参数 void * (* start_routine)(void *),//对应的线程事件,传递函数指针 void * restrict arg//传递参数的变量地址)//成功则返回0,失败则返回其他的值

int pthread_join( pthread_t thread, //线程的编号 void ** status //线程对应的状态,也就是事件返回的指针变量的值 )

线程同步 案例:
#include "common.h"long long num = 0; void* inc(void* arg){ for(int i = 0; i<5000000; i++){ num++; } return NULL; }void * dec(void *arg){ for(int i = 0; i<5000000; i++){ num--; } return NULL; } int main(int argc,char ** argv){ pthread_t t_id1,t_id2; int thread_para1,thread_para2; void *ret_ptr1,*ret_ptr2; if(pthread_create(&t_id1,NULL,inc,(void*)&thread_para1) != 0){ fprintf(stderr,"error!"); exit(1); }if(pthread_create(&t_id2,NULL,dec,(void*)&thread_para2) != 0){ fprintf(stderr,"error2"); exit(1); }if(pthread_join(t_id1,&ret_ptr1) != 0){ fprintf(stderr,"error!"); exit(1); }if(pthread_join(t_id2,ret_ptr2) != 0){ exit(1); }printf("the value of num is : %lld\n",num); }

以上案例是为了证明线程取数据的异常。最后的运行结果(预期为0):
TCP IP网络编程学习笔记
文章图片

可以看到,每一次运行结果都不相同。原因是num在运行的时候是放在寄存器上,每一次对num++或者--的时候,在cpu上复制num的值,进行加或者减,然后再覆盖回对应的寄存器上。如果恰好两个++操作同时进行,那么num的值同时被更新了两次,在数值上只被+了一次。所以需要线程同步,也就是说,不能同时有两个线程对num进行操作。
互斥量API(Mutex):
互斥量初始化:
int pthread_mutex_init(pthread_mutex_t * mutex,const pthread_mutexattr_t *attr); int pthread_mutex_destroy(pthread_mutex_t * mutex);

锁操作:
int pthread_mutex_lock(pthread_mutex_t * mutex); int pthread_mutex_unlock(pthread_mutex_t * mutex);

信号量API(Semaphore):
//信号量的销毁以及创建 #includeint sem_init(sem_t * sem, //信号量的基础变量int pshared, //传递0表示只能够被一个线程享用 unsigned int value); //创建信号量的值 int sem_destroy(sem_t * sem); //销毁信号量

信号量的P、V操作
int sem_post(sem_t * sem); //对信号量的值+1 int sem_wait(sem_t * sem); //对信号量的值-1

sem_wait就相当于用掉一个临界资源。比如输入缓冲区的大小为1,那么在一开始没有输入的时候,输入缓冲的信号量为1 。当获取输入值之后,输入缓冲被用掉,那么就调用一次sem_wait,把信号量的值置为0,表示不能够有其他的线程再来访问当前的输入缓冲区;直到某个线程取走了这个输入数字,输入缓冲再次空出来,那么就用sem_post,信号量+1 。对于输出缓冲也是一个道理。
【TCP IP网络编程学习笔记】案例:线程A从终端输入,线程B取得这个输入,并把输入的值加到num中。
#include "common.h"static sem_t sem_in,sem_out; static int num,tmp; void *produce(void *arg){ for(int i = 0; i<5; i++){ sem_wait(&sem_in); scanf("%d",&tmp); sem_post(&sem_out); } }void *consume(void *arg){ for(int i =0; i<5; i++){ sem_wait(&sem_out); num += tmp; tmp = 0; sem_post(&sem_in); } }int main(int argc,char ** argv){ pthread_t t_in,t_out; int pthread_para1 ,pthread_para2 = 0; void **ret_adr; //at first the input buffer size is 1,so we need to //set the sem_in value is 1 sem_init(&sem_in,0,1); sem_init(&sem_out,0,0); pthread_create(&t_in,NULL,produce,(void*)&pthread_para1); pthread_create(&t_out,NULL,consume,(void*) &pthread_para2); pthread_join(t_in,ret_adr); pthread_join(t_out,ret_adr); sem_destroy(&sem_in); sem_destroy(&sem_out); printf("%d\n",num); }

    推荐阅读