wywwzjj's Blog

CSAPP 网络编程 笔记

字数统计: 5.4k阅读时长: 21 min
2019/12/02 Share

实践项目

实现一个 telnet 版本的聊天服务器,主要有以下需求。

  • 每个客户端可以用使用 telnet ip:port 的方式连接到服务器上。
  • 新连接需要用用户名和密码登录,如果没有,则需要注册一个。
  • 然后可以选择一个聊天室加入聊天。
  • 管理员有权创建或删除聊天室,普通人员只有加入、退出、查询聊天室的权力。
  • 聊天室需要有人数限制,每个人发出来的话,其它所有的人都要能看得到。

实现一个简单的 HTTP 服务器,主要有以下需求。

  • 解释浏览器传来的 HTTP 协议,只需要处理 URL path。
  • 然后把所代理的目录列出来。
  • 在浏览器上可以浏览目录里的文件和下级目录。
  • 如果点击文件,则把文件打开传给浏览器(浏览器能够自动显示图片、PDF,或 HTML、CSS、JavaScript 以及文本文件)。
  • 如果点击子目录,则进入到子目录中,并把子目录中的文件列出来。

实现一个生产者 / 消费者消息队列服务,主要有以下需求。

  • 消息队列采用一个 Ring-buffer 的数据结构。
  • 可以有多个 topic 供生产者写入消息及消费者取出消息。
  • 需要支持多个生产者并发写。
  • 需要支持多个消费者消费消息(只要有一个消费者成功处理消息就可以删除消息)。
  • 消息队列要做到不丢数据(要把消息持久化下来)。
  • 能做到性能很高。

v2ray 文档 https://www.v2ray.com/developer/intro/roadmap.html

自己实现一个 socks5

Python: https://hatboy.github.io/2018/04/28/Python%E7%BC%96%E5%86%99socks5%E6%9C%8D%E5%8A%A1%E5%99%A8

C:https://www.cayun.me/%E7%BD%91%E7%BB%9C/%E7%94%A8c%E8%AF%AD%E8%A8%80%E5%86%99%E4%B8%80%E4%B8%AAsocks5%E4%BB%A3%E7%90%86%E6%9C%8D%E5%8A%A1%E5%99%A8/

概述

早起 TCP/IP 被移植到 UNIX 平台时,设计者们希望像访问文件一样去访问网络。

Linux 提供了三种类型套接口:

  • 流式套接口(SOCK_STREAM)

提供了可靠的双向顺序数据流连接。

  • 数据报套接口(SOCK_DGRAM)

提供双向的数据传输。

  • 原始套接口(SOCK_RAW)

这种套接口允许进程直接存取下层的协议。

现在全世界的人都在解决 C10K 问题。

http://www.kegel.com/c10k.html

翻译版:https://www.oschina.net/translate/c10k

问题

  • TCP 与 UDP 的异同?

    面向连接的可靠传输,有重传机制,还有流量控制、拥塞控制

    面向无连接的不可靠传输,以带数据边界的数据报形式传送,速度快

  • TCP 连接的特点和基本流程,何谓“三次握手”?

    A = seq1 => B

    B = seq1 + 1 / seq2=> A

    A = seq2 + 1 => B

  • 简述 OSI 七层模型和 TCP/IP 模型的异同

    OSI 参考模型只是一个抽象的理论模型,TCP/IP 是实际网络应用中的经验产物,它与 OSI 的四层相关联。

  • 二倍 msl(maximum segment lifetime) 作用?

    结合四次挥手可以很清晰的看到:

    • 可靠的实现 TCP 全双工连接的终止(保证最后一次 ack 到达)
    • 允许老的重复报文在网络中的消逝(旧报文到达导致重复建立连接)
  • 简述 C/S 的运行模型。

    即客户端与服务端运行模型,服务端为客户端提供服务,一直等待客户请求;

    客户端向服务端发出请求,并等待响应结果。

TCP

  • listen 监听列表满了怎么办?

    TCP 将忽略客户传来的 SYN 分节,不发送 RST,客户端将重发 SYN。

  • 在调用 select 函数时,如何使得进程跳出阻塞状态?

    设置信号处理函数、直接指定时间?

  • shutdown、close 区别?

    close:将套接口描述字引用计数器减一,计数器为零套接口才会关闭,并且终止了读写两个方向。

    shutdown:不管引用计数器为何值,直接终止网络连接,可单独指定终止读、写。

  • 网络编程时,为什么要考虑字节顺序问题?

    因为网络字节序与主机字节序不一致。

  • 编程实现:TCP,客户机产生两个随机数,发给两个服务器,A将两数相加,B相减,分别将结果返给客户机。

  • 简述 socket API 的差别:

    send、write、writev、sendto、sendmsg

  • 什么是多路复用 select,给出一个典型应用

    内核发现进程指定的一个或多个I/O条件就绪,就通知进程。典型:多个描述字多路复用,比如交互式输入和网络套接字。

  • 出现粘包如何处理?

UDP与原始套接口

  • UDP协议中发送数据大于缓冲区大小,系统如何处理,说明理由。

    UDP将直接丢弃这个数据报,并且不发送任何报错信息。

  • UDP协议与TCP协议的服务器在处理客户端请求时有何异同?

    UDP采用循环服务器的工作方式,它仅有的单个套接口用于接收所有到达的数据报,并发回所有的响应,UDP套接口有一个接收缓冲区用于存放到来的数据报。

  • 与 TCP 中的 connect 有何差别?

    UDP 不需要建立连接,使用 connect 只是记录目的方的IP与端口,调用后,可直接 readwrite

    而且这里的 read 将不会受到来自其他主机的应答。

  • 如何避免UDP协议下客户端将非服务端发送的应答,误认为是服务器应答?

    • 通过 recvfrom 里返回的 IP 与端口区分
    • 使用 connect
  • 简述ping程序的功能与实现原理。

    利用原始套接口发送 icmp 回射请求,等待对方的应答,应答中包含请求的标识符、序列号、时间戳

  • 简述traceroute程序的功能与实现原理。

    首先发送 ttl 为1的 udp 数据报,然后逐次递增ttl,确定下一跳的路由。

    当 icmp 报文到达目标主机时,目标主机返送一个 icmp 错误,显示端口不可达。

带外数据

  • 什么是带外数据?TCP 协议支持多少个字节的带外数据?

    若连接的某端发生了重要的事情,希望迅速通知对端,这种通知要在发送缓存数据前发送。

    带外数据并不要求在客户与服务器间再使用一个连接,而是映射到已有的连接中。

    只支持一个字节

  • 试给出一个使用带外数据提供的服务。

    心搏函数。

  • TCP 有没有为紧急数据提供单独的数据信道,它是如何实现带外数据传输的?

    TCP 没有单独的通道,而是使用的紧急模式实现的。

  • TCP 发送和接收带外数据有哪些方法?

    • send(sockfd, 'A', 1, MSG_OOB)
  • SIGURG 信号处理函数

    • select 异常集合接收
    • 带外标志读取
  • TCP 协议收到一个新的紧急指针时,将通知接收进程,有哪些通知方法?

    SIGURG 信号、select

  • 如果进程设置了 SO_OOBINLINE 选项,能否通过设置 MSG_OOB 标志来读取带外数据?为什么?应该采用什么方式读取带外数据?

    不能,SO_OOBINLINE 选项表示将紧急数据留到普通的套接口缓冲区,所以正常的 read 就行了。

    可通过 sockatmark 读取带外标识位置。

阻塞与非阻塞

为什么会阻塞?

两个缓冲区:内核缓冲区、进程缓冲区,当内核缓冲区未满足时,该进程将被投入休眠。

什么是非阻塞?

将一个套接口设为非阻塞 => 通知内核,当所请求的 I/O 操作未满足时,不要阻塞该进程,而是返回一个错误

优点:当 I/O 操作不能立即完成时,进程还可以继续后续的操作,提高自身运行效率。

缺点:进程一直处于运行状态,可能占用大量CPU时间,影响其他进程的运行效率。

非阻塞

非阻塞connect三个用途

  • 完成connect需要花一个RTT时间,局域网的几毫秒到广域网的几秒。此期间可以将三次握手迭合在其他处理上
  • 利用非阻塞 connect 技术同时建立多个连接
  • 利用 select 指定时间限制,缩短connect的超时(很多实现中connect超时为75秒到数分钟)
1.设置套接口为非阻塞
2.发起非阻塞 connect
3.等待连接建立期间完成其他事情
4.检查连接是否立即建立
5.调用 select
6.处理 select 超时
7.检查可读可写条件,调用 getsockopt 查看连接是否成功
8.关闭非阻塞状态并返回

I/O 复用

可等待多个描述字的就绪

信号驱动

内核在描述字就绪时,发送 SIGIO 信号通知进程

绑定信号以及对应的处理函数 => 继续执行其他操作 => 满足后自动处理

异步

告知内核启动某个操作,并让内核在整个操作完成(包括将数据从内核拷贝到进程缓冲区里)后通知

与信号驱动的区别:

  • 信号驱动:由内核通知何时可以启动一个 I/O 操作
  • 异步:由内核通知 I/O 何时完成

aio_read 给内核传递描述字、缓冲区指针、缓冲区大小、文件偏移,并告诉内核当操作完成时如何通知进程。

问题

  • 哪种情况下适合采用阻塞式I/O编程?

    访问一个或多个服务进程时,各访问之间有顺序关系

  • 非阻塞与阻塞在 CPU 利用率上有什么区别

    阻塞期间不占用 CPU 时间,不影响其他进程的工作效率,进程可能长时间处于休眠,在此期间进程不能执行别的任务,进程自身的效率不高。

    非阻塞,进程还可以执行后续的任务,提高自身的工作效率,进程一直处于执行期间,可能占用大量CPU时间来检测IO操作是否完成,影响其他进程的执行效率。

  • 哪些套接口会发生阻塞

    // 数据发送 发送缓冲区没有空间
    sendmsg, sendto, send, write, writev

    // 数据接收,接收缓冲区没有空间
    recvmsg, recvfrom, recv, read, readv

    // 完成三次握手
    connect

    // 无新连接到达
    accept

广播

用途

局限于局域网内使用

  • 资源发现

    在本地子网中定位一个服务器主机,寻找其 IP 地址(例如 ARP、DHCP)

  • 节约带宽

    在有多个客户机与单个服务器机通信的局域网环境中尽量减少分组流量。

多播

用途

局域网、跨广域网都可使用

问题

  • 与广播的区别,以及分别的应用场景

    • 广播是向网络中所有主机发送信息
    • 广播由于是向全网发,其他无关主机都会收到,而且要到传输层才能处理,浪费网络、计算资源
    • 广播应用实例:网络时间协议

进程间通信

常见信号及其默认动作

SIGABRT		异常终止(abort)	 		 终止
SIGFPE 算术异常(除以0) 终止
SIGUSR1 用户定义信号 忽略
SIGUSR2 同上
SIGHUP 连接断开(送给控制进程) 终止
SIGALRM 计时器到时(alarm) 终止
SIGCHLD 子进程状态改变 忽略
SIGURG 紧急数据到达 忽略
SIGIO 异步I/O 终止
SIGINT 终端中断符 终止
SIGPIPE 写至无读进程的管套 终止
SIGKILL 终止进程 终止

管道与 FIFO

  • 管道可用于具有亲缘关系进程间的通信
  • 命令管道克服了管道没有名字的限制,命名管道允许无亲缘关系进程间的通信

UNIX 域协议

IPC

消息通信

消息通信通过消息队列实现进程通信

  • 消息队列是消息的链接表
  • 有足够的权限的进程可以向队列中添加消息,被赋予读权限的进程可以读取队列中的消息
  • 消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等特点
  • 消息队列不需要进程间具有亲缘关系

信号与信号量

  • 用于通知接受进程有某事件发生
  • 进程可以发送信号给进程本身
  • 信号 => 信号量,能使用多次?

共享内存

进程能够不涉及内核而访问其中的数据

  • 使用多个进程可以访问同一块内存空间,是单机最快的可用 IPC 形式
  • 针对其他通信机制运行效率较低而设计的,往往与其他通信机制结合来达到进程间的同步和互斥,如信号量

问题

  • 命名管道、管道的区别
    • 命名管道以 FIFO 的形式存在于文件系统中,与 FIFO 创建进程无亲缘关系的进程只要能访问该路径,就能彼此通信
    • 管道在最后一个关闭后自动消失,而 FIFO 需要通过 unlink 删除

并发

多进程

多线程

IO 多路复用

异步I/O模型的发展技术是: select -> poll -> epoll -> aio -> libevent -> libuv。Unix/Linux用了好几十年走过这些技术的变迁,然而,都不如 Windows I/O Completion Port 设计得好(免责声明:这个观点纯属个人观点。相信你仔细研究这些I/O模型后,你会得到你自己的判断)。

一个线程维护多个 Socket

由于 socket 是文件描述符,因而某个线程盯的所有的 socket,都放在一个文件描述符集合 fd_set 中,这就是项目进度强,然后调用 select 函数来监听文件描述符集合是否有变化。一旦有变化,就会依次查看每个文件描述符。那些发生变化的文件描述符在 fd_set 中对应的位都设为 1,表示 socket 可读或者可写,从而可以进行读写操作,然后再调用 select,接着盯下一轮的变化。

从“派人盯着”到”有事通知“

上面的方式在文件描述符有变化时,都会采用轮询的方式确定具体是哪个 socket 有变化,也就是需要将全部项目都过一遍的方式来查看进度,这就大大影响了一个项目组能够支撑的最大的项目数量。因而使用 select,能够同时盯的项目数量由 FD_SETSIZE 限制。

如果改成事件通知的方式,情况就会好很多。(select 里不能返回具体是哪个 socket 变化了?)

最终方式:epoll + callback

AIO: Asynchronous IO ,异步非阻塞

BIO:Block-io,同步且阻塞式IO

NIO:Non-block IO,同步非阻塞

API

杂项

int to char array

sprintf(buf, "%d", num);

端口复用(注意放到 bind 前面)

int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

地址

#include <linux/sock.h>

// 通用型套接字地址结构
struct sockaddr {
unsigned short sa_family; // 地址类型,AF_xxx,2 个字节
char sa_data[14]; // 协议地址,14 个字节
}

// ipv4
struct sockaddr_in {
short int sin_family; // 地址类型:AF_INET
unsigned short int sin_port;// 端口号,16 位 TCP/UDP 端口号网络字节顺序
struct in_addr sin_addr; // 32 位地址
unsigned char sin_zero[8];
}

字节操纵

#include <string.h>
void bzero(void* s, size_t n); // n 个字节置零
void bcopy(const void* src, void* dest, size_n); // 拷贝 n 个字节
int bcmp(const void* s1, const void* s2, size_t n); // 相等返回 0

#include <string.h>
void *memset(void *s, int c, size_t n); // 将目标中n个字节设置为值c
void *memcpy(void *dest, const void *src, size_t n); // 拷贝字符串中n个字节
int memcmp(const void *s1, const void *s2, size_t n); // 字符串比较,相等返回0;不等返回非0

IP 地址转换

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

/* h 表示 host,n 表示 network,l 表示 32 位整数,s 表示 16 位短整数 */

// 点分十进制字符串 => 网络字节顺序二进制值
int inet_aton(const char *cp, struct in_addr *inp);

// 点分十进制字符串 => 网络字节顺序二进制值
unsigned long int inet_addr(const char *cp);
// 以255.255.255.255表示出错,不能表示此广播地址

// 网络字节顺序二进制值 => 点分十进制字符串
char * inet_ntoa(struct in_addr in);


int inet_pton(int af, const char * strptr, void *dst);
// 成功返回1;输入无效表达式格式返回0;出错返回-1

const char* inet_ntop(int af, const void * strptr, char *dst, size_t cnt);
// 成功返回结果指针dst;出错返回NULL

创建

> #include<sys/types.h>
> #include<sys/socket.h>
>

socket

int socket(int domain, int type, int protocol);

// domain:协议族。type:套接口类型,protocol:协议类型
// 返回值:-1 出错,非负值则为套接口描述字


int socketpair(int family, int type, int protocol, int fd_array[2]);

bind

将套接口指定IP、port,可两者都指定,也可都不指定;

服务端通常在启动时绑上端口;

客户端通常不绑定端口,由内核分配临时端口;

可通过 getsockname 来返回协议地址。

int bind(int sockfd, struct sockaddr *my_addr, int addrlen);

// 成功则返回0,失败返回-1

listen

监听本地地址和端口

套接口状态:closed => listen

sockfd-已绑定的socket描述符
backlog-已完成连接、等待接收的队列长度,LISTENQ?

int listen(int sockfd, int backlog);

// 成功则返回0,失败返回-1,错误原因存于errno

accept

当服务请求到达 accept 监视的 socket(监听套接口)时,系统将自动建立一个新的 socket(已连接套接口),并将此 socket 和客户进程连接起来。

int accept(int sockfd, sockaddr* cliaddr, int *addrlen);

收发

#include <unistd.h>

read

从套接口接收缓冲区中读取len字节的数据,成功返回,返回值是实际读取数据的字节数

ssize_t read(int fd, void *buf, size_t count);

/* 返回值:
无数据 => 阻塞
n >= len => len
n > 0 && n < len => 读取 n 个
n = 0 => 读通道关闭
n < 0 => 出错或异常
n = -1, errno == EINTR => 读中断引起错误
n = -1, errno == ECONNREST => 网络连接有问题
read 函数要求操作系统内核从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况;如果返回值为 -1,表示出错。
*/


/* 从 socketfd 描述字中读取 "size" 个字节. */
ssize_t readn(int fd, void *vptr, size_t size) {
size_t nleft = size;
ssize_t nread;
char* ptr = vptr;

while (nleft > 0) {
if ((nread = read(fd, ptr, nleft)) < 0) {
if (errno == EINTR)
nread = 0; /* 这里需要再次调用 read */
else
return(-1);
} else if (nread == 0)
break; /* EOF(End of File) 表示套接字关闭 */

nleft -= nread;
ptr += nread;
}
return n - nleft; /* 返回的是实际读取的字节数 */
}

write

从套接口中发送 len 字节的数据,成功返回,返回实际写入数据的字节数

ssize_t write(int fd, const void *buf, size_t count);

recv

int recv(int fd, void *buf, int len, unsigned int flags);

recvfrom

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);

send

int send(int fd, const void *msg, int len, unsigned int flags);

sendto

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
// socklen_t 不需要指针

recvmsg

sendmsg

连接

connect

TCP 客户端与服务器建立连接用 connect 函数

int connect(int sockfd, struct sockaddr * addressp, int addrlen);

关闭

shutdown

终止网络连接并停止所有信息的发送与接收(不管引用计数器为何值)

#include <sys/socket.h>
int shutdown(int sockfd, int how);

// sockfd:套接口描述字
// how:套接口关闭方式,SHUT_RD、SHUT_WR、SHUT_RDWR

close

计数器减一,不会完全关闭

参数

getsockopt/setsockopt

地址

gethostbyaddr getaddrbyhost,...

非阻塞

int flag = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flag|O_NONBLOCK);

int nIO = 1;
ioctl(sockfd, FIONBIO, &nIO);

复用

fd_set

void FD_ZERO(fd_set * fdset); 			// 清除描述字集 fdset 中的所有位
void FD_SET(int fd, fd_set *fdset); // 在 fdset 集中加入fd描述字(为什么要事先添加?
void FD_CLR(int fd, fd_set *fdset); // 将 fd 从 fdset 中清除
int FD_ISSET(int fd, fd_set *fdset); // 判断 fd 是否在 fdset 中(而不是看是否为1?

select

#include <sys/select.h>
#include <sys/time.h>
int select(int fdmax, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,struct timeval *timeout);
// select 后,要注意复原 fd_set

poll

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

// POLLIN / POLLOUT / POLLERR
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 需要等待的事件 */
short revents; /* 实际发生了的事件,返回值 */
};

epoll

//创建 epoll
int epfd = epoll_crete(1000);

//将 listen_fd 添加进 epoll 中
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd,&listen_event);

while (1) {
//阻塞等待 epoll 中 的fd 触发
int active_cnt = epoll_wait(epfd, events, 1000, -1);

for (i = 0 ; i < active_cnt; i++) {
if (evnets[i].data.fd == listen_fd) {
//accept. 并且将新accept 的fd 加进epoll中.
}
else if (events[i].events & EPOLLIN) {
//对此fd 进行读操作
}
else if (events[i].events & EPOLLOUT) {
//对此fd 进行写操作
}
}
}

信号

static void sig_alrm(int signo) {
return; // 这里的处理对原阻塞是怎么处理的?
}

signal(SIGALRM, sig_alrm); // 绑定信号处理函数
alarm(3);
CATALOG
  1. 1. 实践项目
  2. 2. 概述
    1. 2.1. 问题
  3. 3. TCP
  4. 4. UDP与原始套接口
  5. 5. 带外数据
  6. 6. 阻塞与非阻塞
    1. 6.1. 为什么会阻塞?
    2. 6.2. 什么是非阻塞?
    3. 6.3. 非阻塞
    4. 6.4. I/O 复用
    5. 6.5. 信号驱动
    6. 6.6. 异步
    7. 6.7. 问题
  7. 7. 广播
    1. 7.1. 用途
  8. 8. 多播
    1. 8.1. 用途
    2. 8.2. 问题
  9. 9. 进程间通信
    1. 9.1. 管道与 FIFO
    2. 9.2. UNIX 域协议
    3. 9.3. IPC
      1. 9.3.1. 消息通信
      2. 9.3.2. 信号与信号量
      3. 9.3.3. 共享内存
    4. 9.4. 问题
  10. 10. 并发
    1. 10.1. 多进程
    2. 10.2. 多线程
    3. 10.3. IO 多路复用
      1. 10.3.1. 一个线程维护多个 Socket
      2. 10.3.2. 从“派人盯着”到”有事通知“
  11. 11. API
    1. 11.1. 杂项
    2. 11.2. 地址
    3. 11.3. 创建
    4. 11.4. 收发
    5. 11.5. 连接
    6. 11.6. 关闭
    7. 11.7. 参数
    8. 11.8. 地址
    9. 11.9. 非阻塞
    10. 11.10. 复用
    11. 11.11. 信号