高并发应用:TCP网络编程
Go网络编程
什么是非阻塞IO
Socket
- 很多系统都提供Socket作为TCP网络连接的抽象
- Linux-> internet domain socket -> SOCK_STREAM
- Linux中Socket以“文件描述符”FD作为标识。
IO模型
- IO模型指的是同时操作Socket的方案。
- 阻塞
- 非阻塞
- 多路复用
阻塞IO
- 同步读写Socket时,线程陷入内核态。
- 当读写成功后,切换回用户态,继续执行。
- 优点:开发难度小,代码简单。
- 缺点:内核态切换开销大。
非阻塞IO
- 如果暂时无法收发数据,会返回错误。
- 应用会不断轮询,直到Socket可以读写。
- 优点:不会陷入内核态,自由度高。
- 缺点:需要自旋轮询。
多路复用-Linux epoll
- 注册多个Socket事件
- 调用epool,当有事件发生,返回
- 优点:提供了事件列表,不需要查询各个Socket。
- 缺点:开发难度大,逻辑复杂。
- Linu:epoll; Mac:Kqueue; Windows:IOCP
Go是如何抽象Epoll的?
阻塞模型+多路复用
- 在底层使用操作系统的多路复用IO
- 在协程层次使用阻塞模型
- 阻塞协程时,休眠协程。
epoll抽象层
- epoll抽象层是为了统一各个操作系统度多路复用器的实现。
netpollinit()新建多路复用器
- 新建Epoll。
- 新建一个pipe管道用于中断Epoll。
- 将“管道有数据到达”事件注册在Epoll中。
netpollopen()插入事件
- 传入一个Socket的FD,和pollDesc指针。
- pollDesc指针是Socket相关详细信息。
- pollDesc中记录了哪个协程休眠在等待此Socket。
- 将Socket可读、可写、断开事件注册到Epoll中。
netpoll()查询发生了什么事件
- 调用epoll_wait(),查询有哪些事件发生。
- 根据Socket相关的pollDesc信息,返回哪些协程可以唤醒。
Network Poller是如何工作的
NetWork Poller初始化
- poll_runtime_pollServerInit()。
- 使用原子操作保证只初始化一次。
- 调用netpollinit()
pollcache与pollDesc
- pollcache:一个带锁的链表头。
- pollDesc:链表的成员。
- pollDesc是runtime包对Socket的详细描述。
- rg, wg:1,或2,或等待的协程G的地址。
NetWork Poller新增监听Socket
- poll_runtime_pollOpen()
- 在pollcache链表中分配一个pollDesc。
- 初始化pollDesc(rg wg为0)
- 调用netpollopen()
Network Poller收发数据
收发数据分为两个场景
- 协程需要收发数据时,Socket已经可读写。
- 协程需要收发数据时,Socket暂时无法读写。
场景1:Socket已经可读写
- runtime会循环调用netpoll()方法(g0协程)
- 发现Socket可读写时,给对应的rg或者wg置为pdReady(1)
- 协程调用poll_runtime_pollWait()
- 判断rg或者wg已经置为pdReady(1),返回0.
场景2:Socket暂时无法读写
- runtime循环调用netpoll()方法(g0协程)
- 协程调用poll_runtime_pollWait()
- 发现对应的rg或者wg为0
- 给对应的rg或者wg置为协程地址
- 休眠等待
- runtime循环调用netpoll()方法(g0协程)
- 发现Socket可读写时,给对应的查看对应的rg或者wg
- 若为协程地址,返回协程地址。
- 调度器开始调度对应协程
总结
- Network Poller是runtime的强大工具
- 抽象了多路复用器的操作
- Network Poller可以自动监测多个Socket状态
- 在Socket状态可用时,快速返回成功。
- 在Socket状态不可用时,休眠等待。
Go是如何抽象Socket的?
net包
- net包是Go原生的网络包
- net包实现了TCP、UDP、HTTP等网络操作。
net.Listen()
- 新建Socket,并执行bind操作
- 新建一个FD(net包对Socket的详情描述)
- 返回一个TCPListener对象
- 将TCPListener的FD信息加入监听。
- TCPListener对象本质上是一个LISTEN状态的Socket。
TCPListener.Accept()
- 直接调用Socket的accept()
- 如果失败,休眠等待新的连接。
- 将新的Socket包装为TCPConn变量返回
- 将TCPConn的FD信息加入监听
- TCPConn本质上是一个ESTABLISHED状态的Socket。
TCPConn.Read() / Write()
- 直接调用Socket原生读写方法。
- 如果失败,休眠等待可读/可写。
- 被唤醒后调用系统Socket。
总结
- net包抽象了TCP网络操作。
- 使用net.Listen()得到TCPListener(LISTEN状态的Socket)
- 使用TCPListener.Accept()得到TCPConn(ESTABLISHED)
- TCPConn.Read() / Writer()进行读写Socket的操作。
- Network Poller作为上述功能的底层支撑。
实战:怎样结合阻塞模型和多路复用?
思路
- 用主协程监听Listener
- 每个Conn使用一个新协程处理
goroutine-per-connection编程风格
/*
实现一个简单的TCP Server
*/
func ListenAndServe(address string) {
// 绑定监听地址
listen, err := net.Listen("tcp", address)
if err != nil {
log.Fatal(fmt.Sprintf("listen err: %v", err))
}
defer listen.Close()
log.Println(fmt.Sprintf("bind: %s, start listening...", address))
for true {
// Accept会一直阻塞直到有新的连接建立或者Listen中断才返回
conn, err := listen.Accept()
if err != nil {
// 通常是由于listen被关闭导致无法继续监听导致的错误
log.Fatal(fmt.Sprintf("accept err: %v", err))
}
// 开启新的Goroutine处理该连接
go Handle(conn)
}
}
func Handle(conn net.Conn) {
// 使用bufio标准库提供的缓冲区功能
reader := bufio.NewReader(conn)
for {
// ReadString 会一直阻塞直到遇到分隔符'\n'
// 遇到分隔符会返回分隔符或连接建立后收到的所有数据,包括分隔符本身
// 若在遇到分隔符之前遇到异常,ReadString会返回已收到的数据和错误信息
msg, err := reader.ReadString('\n')
if err != nil {
// 通常遇到的错误是连接中断或被关闭,用io.EOF表示
if err == io.EOF {
log.Println("connection close")
} else {
log.Println(err)
}
return
}
b := []byte(msg)
// 将收到的信息发送给客户端
conn.Write(b)
}
}
func main() {
ListenAndServe(":8080")
}
运行结果
2024/02/13 00:21:08 bind: :8080, start listening...
2024/02/13 00:23:49 connection close
2024/02/13 00:23:55 connection close