v4if / blog

:octocat: 用issues写博客,记录点滴
34 stars 7 forks source link

UNP笔记 #7

Open v4if opened 7 years ago

v4if commented 7 years ago

Unix Network Programming

TCP/IP协议族

数据链路:1500字节以太网的MTU大小 网络层:IPv4 IPv6

传输层:TCP UDP [TCP和UDP之间留有间隙,表明网络应用绕过传输层直接使用IPv4或IPv6是可能的,就是所谓的原始套接字] | 应用层

#include <stdio.h>
int main()
{
  printf("%d\r\n", sizeof(int));
  printf("%d\r\n", sizeof(int *));

  return 0;
}

> 4
> 8

TCP与UDP的区别

每个UDP数据报都有一个长度。如果一个数据报正确的到达其目的地,那么该数据报的长度将随数据一道传递给接收端应用进程。而TCP是一个字节流(byte-stream)协议,没有任何记录边界

UDP提供无连接的服务,UDP客户与服务器之间不必存在任何长期的关系。一个UDP服务器可以用同一个UDP套接字从若干个不同的客户接收数据报,每个客户一个数据报

TCP通过给其中每个分节关联一个序列号对所发送的数据进行排序,提供动态估算往返时间RTT,超时和重传等机制,同事提供流量控制(flow control),TCP连接是全双工的。

MSS选项。发送SYN的TCP一端使用本选项通告对端它的最大分节大小(maximum segment size),也就是它在本连接的每个TCP分节中愿意接受的最大数据量。发送端TCP使用接收端的MSS值作为所发送分节的最大大小。 最小重组缓冲区大小(536 MSS) - 单个TCP分节大小 MSS经常设置成MTU减去IP和TCP首部的固定长度。在以太网中使用IPv4的MSS值为1460,使用IPv6的MSS值为1440(两者的TCP首部都是20字节,但IPv4首部是20字节,IPv6首部是40字节)

ping和traceroute是使用ICMP协议实现的网络诊断应用。traceroute自行构造UDP分组来发送并读取所引发的ICMP应答

TIME_WAIT

TIME_WAIT状态 该端点停留在这个状态的持续时间是最长分节生命期(maximum segment lifetime, MSL)的两倍,称之为2MSL

端口号

TCP、UDP协议都使用16位整数的端口号(port number)来区分这些进程 0-1023~1024-65535 服务器通常使用熟知的端口号来标识某个服务,而客户端通常使用短期存活的临时端口,这些端口号通常由传输层协议自动赋予客户。

一个TCP连接的套接字对(socket pair)是一个定义该连接的两个端点的四元组:本地IP地址、本地TCP端口号、外地IP地址、外地TCP端口号。 bind函数要求应用程序给TCP、UDP套接字指定本地IP地址和本地端口号

数据报大小的一些限制

IPv4数据报的最大大小是65535字节,包括IPv4首部。 IPv4的DF(don't fragment)位和隐含DF位可用于路径MTU发现。

缓冲区写数据

内核直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区,才从write系统调用返回。从写一个TCP套接字的write调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接收到数据 write_buff

基本套接字编程

当作为一个参数传递进任何套接字函数时,套接字地址结构总是以引用形式(也就是以指向该结构的指针)来传递

// 通用套接字地址结构sockaddr
int bind(int, struct sockaddr*, socklen_t);

bind(sockfd, (strcut sockaddr*) &serv, sizeof(serv));

不同套接字地址结构 sockaddr_struct

套接字地址结构是在进程和内核之间传递的 (1)从进程到内核传递套接字地址结构的函数有3个:bind、connect和sendto,这些函数的一个参数是指向某个套接字地址结构的指针,另一个参数是该结构的整数大小 connect(sockfd, (SA) &serv, sizeof(serv)); (2)从内核到进程传递套接字地址结构的函数有4个:accept、recvfrom、getsockname和getpeername,这4个函数的其中两个参数是指向某个套接字地址结构的指针和指向表示该结构大小的证书变量的指针。 len = sizeof(cli); / len is a value / getpeername(sockfd, (SA) &serv, &len); 把套接字地址结构大小这个参数从一个整数改为指向某个整数变量的指针,其原因在于:当函数被调用时,结构大小是一个值(value),它告诉内核该结构的大小,这样内核在写该结构时不至于越界;当函数返回时,结构大小又是一个结果(result),它告诉进程内核在该结构中究竟存储了多少信息。这种类型的参数被成为值-结果(value-result)参数

字节序

术语小端大端表示多个字节值的哪一端(最低有效位,最高有效位),存储在该值的起始位置。 多字节发送需要考虑网络和本地字节序。

// 传递二进制结构和文本字符串
struct args {
  long arg1;
  long arg2;
};
// 二进制结构 需要考虑字节序
write(sockfd, &args, sizeof(args));

char line[MAXLINE];
// 文本字符串
write(sockfd, line, strlen(line));

解决方法通常是: 1.把所有的数值数据作为文本串来传递。 2.显示定义所支持数据类型的二进制格式(位数、大端或小端字节序),并以这样的格式在客户与服务器之间传递所有数据。

union {
  short s;
  char c[sizeof(short)];
} un;
un.s = 0x0102;

在每个TCP分节中都有16位的端口号和32位的IPv4地址。发送协议栈和接收协议栈必须就这些多字节字段各个字节的传送顺序达成一致。网际协议中使用大端字节序来传送这些多字节整数。

// Berkeley
#include <strings.h>
void bzero(void *dest, size_t nbytes);
void bcopy(const void *src, void *dest, size_t nbytes);
int bcmp(const void *ptr1, const void *ptr2, size_t nbytes);

// ANSI c
#include <string.h>
void *memset(void *dest, int c, size_t len);
void *memcpy(void *dest, const void *src, size_t nbytes);
int memcmp(const void *ptr1, const void *ptr2, size_t nbytes);

addr转换

inet_pton和inet_ntop中p和n分别代表表达(presentation)和数值(numeric)

客户端、服务器模型

client_server_model

tcp_timeline

tcp_state_trans

#include <sys/socket.h>
// 协议族,套接字类型,协议类型
// SOCK_STREAM-字节流套接字  SOCK_DGRAM-数据报套接字
int socket(int family, int type, int protocol);

// 激发TCP的三次握手过程,而且仅在连接建立成功或出错时才返回
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);

// bind函数把一个本地协议地址赋予一个套接字 
// 通配地址 INADDR_ANY
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);

// SYN_RCVD和ESTABLISHED两队列之和不超backlog OR 模糊因子,乘以1.5得到未处理队列最大长度
// backlog参数的确切含义,它应该指定某个给定套接字上内核为之排队的最大已完成连接数
int listen(int sockfd, int backlog);

// 从已完成连接队列队头返回一个已完成连接,如果该队列为空,那么进程被投入睡眠
// 假定套接字为默认的阻塞方式
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

#include <unistd.h>
int close(int sockfd);
/*
 close一个TCP套接字的默认行为是把该套接字标记成已关闭,然后立即返回到调用进程。
 该套接字描述符不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。
 然而TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接终止序列。
 */
#include <unistd.h>
// 调用一次,返回两次
// 父进程中调用fork之前打开的所有描述符在fork返回之后由子进程共享
/*
 我们将看到网络服务器利用了这个特性:父进程调用accept之后调用fork。所接受的已连接套接字随后就在父进程与子进程之间共享。
 通常情况下,子进程接着读写这个已连接套接字,父进程则关闭这个已连接套接字。
 */ 
pid_t fork(void);
/*
 fork有两个典型的用法:一个进程创建一个自身的副本 or 一个进程想要执行另一个程序,exec把当前进程映像替换成新的程序文件,而且该新程序通常从main函数开始执行。进程ID并不改变。
 进程在调用exec之前打开着的描述符通常跨exec继续保持打开
 */
/*
 这6个exec函数之间的区别在于:
 1. 待执行的程序文件是由文件名(filename)还是由路径名(pathname)指定
 2. 新程序的参数是一一列出还是由一个指针数组来引用
 3. 把调用进程的环境传递给新程序还是给新程序指定新的环境
 */
// 以空指针结束可变数量的这些参数
int execl(const char *pathname, const char *arg0, ... /* (char*)0 */);
// 常量指针 declare argv as array of const pointer to char
// 没有指定参数字符串的数目,argc数组必须含有一个用于指定其末尾的空指针
int execv(const char *pathname, char *const argv[]); 
  // 原书这里有笔误:char *const *argv[] 
int execle(const char *pathname, const char *arg0, ... /* (char*)0, char *const envp[] */); 
int execve(const char *pathname, char *const argv[], char *const envp[]); 
int execlp(const char *filename, const char *arg0, ... /* (char*)0 */);
int execvp(const char *filename, char *const argv[]);
/*
 这些函数只在出错时才返回到调用者。否则,控制将被传递给新程序的起始点,通常就是main函数
 */

exec函数之间的关系: exec_fam_function

close

描述符引用计数:并发服务器中父进程关闭已连接套接字只是导致相应描述符的引用计数减1

SIGCHLD

在服务器子进程终止时,给父进程发送一个SIGCHLD信号,如果父进程未加处理,该信号的默认行为是被忽略,子进程于是进入僵死状态--必须处理僵死进程 无论何时fork子进程都得wait它们,以防变成僵死进程

// 信号处理函数由信号值这个单一的整数参数来调用,且没有返回值,原型如下:
void handler(int signo);
// 第一个参数是信号名,第二个参数指向函数的指针
void (*signal(int signo, void (*handler)(int))) (int);

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

简单wait,还是会有僵尸进程,而且每次僵尸进程的数量都不定。Linux的信号机制是不排队的,假如在某一时间段多个子进程退出后都会发出SIGCHLD信号,但父进程来不及一个一个地响应,所以最后父进程实际上只执行了一次信号处理函数。但执行一次信号处理函数只等待一个子进程退出,所以最后会有一些子进程依然是僵尸进程。 如果调用wait的进程没有已终止的进程,不过有一个或多个子进程仍在执行,那么wait将阻塞到现有子进程第一个终止为止。 waitpid函数的pid参数允许我们指定想等待的进程ID,值-1表示等待第一个终止的子进程。options参数允许我们指定附加选项。最常用的选项就是WNOHANG,它告知内核在没有已终止子进程时不要阻塞。

I/O多路复用

阻塞在一个IO未接收到其他通知:当TCP客户同时处理两个输入:标准输入和TCP套接字。我们遇到的问题就是在客户阻塞于(标准输入上的)fgets调用期间,服务器进程会被杀死。服务器TCP虽然正确的给客户TCP发送了一个FIN,但是既然客户进程正阻塞于从标准输入读入的过程,它将看不到这个EOF,直到从套接字读时为止(可能已经过了很长时间)。这样的进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备号被读取,或者描述符已能承接更多的输出),它就通知进程。这个能力称为I/O复用,是由select和poll这两个函数支持的。 有些系统提供了让进程在一串事件上等待的机制。轮询设备(poll device)就是这样的机制之一。

I/O模型:阻塞式I/O、非阻塞式I/O、I/O复用(select和poll)、信号驱动式I/O(SIGIO)、异步I/O(POSIX的aio_系列函数)

一个输入操作通常包含两个不同的阶段:

  1. 等待数据准备好,对一个套接字上的操作,第一步通常等待数据从网络中到达。当所等待的分组到达时,它被复制到内核中的某个缓冲区。
  2. 从内核向进程复制数据,把数据从内核缓冲区复制到应用进程缓冲区。
#include <sys/select.h>
#include <sys/time.h>
// 允许进程指示内核等待多个事件中的任何一个发生
// 忘了对最大描述符+1,忘了描述符集是值-结果参数
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);

struct timeval {
  long tv_sec; // seconds
  long tv_usec; // microseconds 微秒   millisecond-毫秒
};

#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
struct pollfd {
  int fd;
  short events;
  short revents;
};

当一个服务器在处理多个客户时,它绝对不能阻塞于只与单个客户相关的某个函数调用,否则可能导致服务器被挂起,拒绝为所有其他客户提供服务。这就是所谓的拒绝服务型攻击。 可能的解决办法包括:1. 使用非阻塞式I/O; 2. 让每个客户由单独的控制线程提供服务; 3. 对I/O操作设置一个超时。

基本UDP套接字编程

#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags, const struct sockaddr *to socklen_t addrlen);

非阻塞式I/O

select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

待深究问题

socket backlog : tcp 队列{syn队列、accept队列} 对于select、epoll + 非阻塞I/O理解还不够 socket缓冲区 需要重新细看一下第16章:非阻塞式I/O