2020校招面试
个人情况
岗位:后台 golang 开发
公司:字节 shopee 拼多多百度阿里快手都有
途径:提前批内推
学历:某 985 大三
实习情况:一段小厂实习
项目和准备:实习时候做的是一个分布式存储系统以及一个 kv 数据库,这也是面试的重点
计算机网络
1,tcp 中 timewait 状态的作用,为什么要等待两个 msl
2,tcp 中三次挥手开启连接,四次握手关闭连接的流程
3,聊聊 tcp 的滑动窗口
4,ssl 建立过程
5,输入一个 url 的过程
6,大文件传输
1,答:为了确认被动关闭端接受到最后一个ack,避免主动关闭端重新在相同端口启动连接后发送syn后被动关闭端认为上一个连接没有完全关闭,进而返回rst终止连接
2,答:就是那个著名的连接建立图
3,答:滑动窗口由接受窗口和拥塞窗口中的最小值,然后就是reno的慢启动,拥塞控制,快速重传三个步骤。然后我还谈了谈cubic算法。
4,答:很详细的描述。从客户端发送clienthello包括ssl版本,对称算法,第一个不重数,mac算法,公钥算法。重点是一共生成了三个不重复数,从主密钥解出了四个密钥,两个用于会话加密,两个用于mac加密。为什么是两个呢,因为一个用于客户端到服务器的会话加密,另一个用于服务器到客户端的会话加密。这里要提醒证书机制并不是完全安全的,因此有EXPECT_CT这个浏览器的头,防止证书颁发机构被劫持。
5,答:从dns从浏览器,操作系统host文件解析。到http的hsts连接建立过程(302,307等状态码),到浏览器缓存etag,以及dom树和css树解析,绘图和渲染,js事件循环https://juejin.im/post/6844903922084085773。
6,答:文件分块。服务端返回206表示部分数据,416表示范围出错。添加一个Range header表示发送的数据的范围。
linux io
首先需要对 linux 五种 io 模型和 epoll 有一定的了解,这里推荐一篇文章https://juejin.im/post/5c725dbe51882575e37ef9ed。
在传输文件的时候,有 sendfile 语意,用于高效的传输文件。
减少了内核上下文切换以及 cpu 复制的损耗。
这里引用一篇文章https://juejin.im/post/6844903949359644680#heading-17。
在 go 语言里头,当我们使用 io.copy 的时候,会判断目标是否实现 readerFrom 接口。
如果实现了就会调用 readerFrom,那么 readerFrom 和普通磁盘 io 的区别在哪呢?
// copyBuffer is the actual implementation of Copy and CopyBuffer.
// if buf is nil, one is allocated.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
// If the reader has a WriteTo method, use it to do the copy.
// Avoids an allocation and a copy.
if wt, ok := src.(WriterTo); ok {
return wt.WriteTo(dst)
}
// Similarly, if the writer has a ReadFrom method, use it to do the copy.
if rt, ok := dst.(ReaderFrom); ok {
return rt.ReadFrom(src)
}
在 go 语言里头,tcpconn 实现了这个接口。我们会发现它会用 splice 和 sendfile 两个系统调用去获取数据。如果 sendfile 系统也不支持,那么就会做一个优雅降级的处理,转换为普通的 io.Copy(隐藏 readerFrom 接口)。
func (c *TCPConn) readFrom(r io.Reader) (int64, error) {
if n, err, handled := splice(c.fd, r); handled {
return n, err
}
if n, err, handled := sendFile(c.fd, r); handled {
return n, err
}
return genericReadFrom(c, r)
}
普通的磁盘 io 传输。
一,发起 read 系统调用,从用户态切换到内核态。
二,cpu 通过控制 dma,将数据从硬件缓冲区 copy 到内核缓冲区。再将内核缓冲区的数据 copy 到用户缓冲区。
三,系统调用结束,从内核态切换为用户态。
四,发起 write 系统调用,同上。
整个操作两次 cpu copy,两次 dma copy,四次上下文切换,两次系统调用。
sendfile
一,sendfile 系统调用,从用户态切换到内核态
二,数据从硬件缓冲区通过 dma copy 复制到内核缓冲区;网卡的硬件缓冲区直接从磁盘对应数据的内核缓冲区读取数据
三,sendfile 调用结束,从内核态切换为用户态
整个过程两次 dma copy,两次上下文切换,0 次 cpu copy,一个系统调用。
socket
而关于 tcp 这块,socket 编程也需要了解,下面我们就来看看 go 中 socket 编程的流程。
net.dialTcp 是如何包装 linux 系统调用的 socket 的。
客户端
使用 socket 建立连接
linux 操作系统把各种 tcp,udp 连接抽象化成 socket,而 go 通过调用 linux 系统调用来建立连接。linux 中一切节文件,SYS_SOCKET 返回的一个数字就代表着文件的进程打开文件描述符的句柄。type 表示 socket 类型,我们建立一个 tcp 连接,就使用的是 SOCK_STREAM,表示一个流式连接;proto 表示协议,IPPROTO_TCP 表示连接传输协议。domain 表示协议域,AF_INET、AF_INET6 表示 ip4,ip6 的协议。
func socket(domain int, typ int, proto int) (fd int, err error) {
r0, _, e1 := RawSyscall(SYS_SOCKET, uintptr(domain), uintptr(typ), uintptr(proto))
fd = int(r0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}
将 socket 注册为 nonblock 和 closeonexec 形式。当我们向 socket 读取数据的时候,是从设备缓冲区到内核缓冲区再到用户缓冲区,而 nonblock 会当设备缓冲区中数据没有准备好时返回一个 EAGAIN 错误。closeonexec 可以参考这篇文章https://blog.csdn.net/ljxfblog/article/details/41680115
syscall.CloseOnExec(s)
syscall.SetNonblock(s, true)
在 epoll 注册 socket。当向 socket 写入数据的时候,首先使用系统调用 write,如果返回 eagain 错误,则休眠当前 goroutine,等待 epoll 唤醒。
注册为 nodelay(禁止 nigle 算法)
dodialtcp 用于建立一条 tcp 连接,包括本端地址和外目标地址。internetsocket 就是完成我们之前所说的事。之后的错误处理主要是因为,当建立连接时如果没有源端口,那么就会随机选择一个端口,由于 tcp 能够同时建立连接;那么很可能出现一条连接,目的端口和接受端口一致而且目的地址和接受地址一样的情况。我们这里就是为了避免出现这种情况
func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)
// TCP has a rarely used mechanism called a 'simultaneous connection' in
// which Dial("tcp", addr1, addr2) run on the machine at addr1 can
// connect to a simultaneous Dial("tcp", addr2, addr1) run on the machine
// at addr2, without either machine executing Listen. If laddr == nil,
// it means we want the kernel to pick an appropriate originating local
// address. Some Linux kernels cycle blindly through a fixed range of
// local ports, regardless of destination port. If a kernel happens to
// pick local port 50001 as the source for a Dial("tcp", "", "localhost:50001"),
// then the Dial will succeed, having simultaneously connected to itself.
// This can only happen when we are letting the kernel pick a port (laddr == nil)
// and when there is no listener for the destination address.
// It's hard to argue this is anything other than a kernel bug. If we
// see this happen, rather than expose the buggy effect to users, we
// close the fd and try again. If it happens twice more, we relent and
// use the result. See also:
// https://golang.org/issue/2690
// https://stackoverflow.com/questions/4949858/
//
// The opposite can also happen: if we ask the kernel to pick an appropriate
// originating local address, sometimes it picks one that is already in use.
// So if the error is EADDRNOTAVAIL, we have to try again too, just for
// a different reason.
//
// The kernel socket code is no doubt enjoying watching us squirm.
for i := 0; i < 2 && (laddr == nil || laddr.Port == 0) && (selfConnect(fd, err) || spuriousENOTAVAIL(err)); i++ {
if err == nil {
fd.Close()
}
fd, err = internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)
}
if err != nil {
return nil, err
}
return newTCPConn(fd), nil
}
服务器
使用 socket 建立一条连接,使用 listen 将 socket 注册为 listen 状态,bind 系统调用绑定端口
通过 accept 从 socket 接受连接。accept 首先通过系统调用 accept 等待连接,如果返回 eagain 错误则 epoll 等待。如果是 connectaborted 错误,则重试。connectionaborted 表示 tcp“三次握手” 后,又发送了一个 rst 中断连接。
对于通过 accept 接受到的连接,也仅仅是一个 int 类型的 fd,我们要通过 epoll 包装连接。
然后设置 keepalive 为默认的 15s 间隔。keepalive 设置原则可以参考这里https://github.com/golang/go/issues/23459keepcnt 的不同,因此我们将 keepalive 设置为 15s,在 keepcnt 最大为 9 的 linux 系统上,超时事件为 150s。主要为了适应不同操作系统上 < 3min。
func (ln *TCPListener) accept() (*TCPConn, error) {
fd, err := ln.fd.accept()
if err != nil {
return nil, err
}
tc := newTCPConn(fd)
if ln.lc.KeepAlive >= 0 {
setKeepAlive(fd, true)
ka := ln.lc.KeepAlive
if ln.lc.KeepAlive == 0 {
ka = defaultTCPKeepAlive
}
setKeepAlivePeriod(fd, ka)
}
return tc, nil
}
学习经验
书籍推荐《计算机网络-自顶向下》。
一些业界比较新的算法也要会:cubic,bbr,quic。quic 推荐这篇文章https://zhuanlan.zhihu.com/p/32553477bbr 推荐这篇文章 https://www.jianshu.com/p/08eab499415a。,
tcp 的一些 option 字段要了解:sack,timestamp。
关于学习 http 中繁琐的 header,这里推荐使用 chrome 浏览器观察 GitHub 的 http 连接建立的过程,观察使用了哪些 header,如何使用 cookie 的(ps:可以学到很多的浏览器安全比如 xss 等知识)
而很多知识比如说 socket 的连接建立其实光看博客很抽象化,最好去看看 go 语言内部的源码。而往往在看 go 源码的时候又会顺藤摸瓜学习到很多知识。
面试经验
一,一些重要的数据结构题还是要背一背的,不然面试官说让你写一个堆排序,事实上是让你写一个 for 循环的,考虑到 int 类型溢出的排序,这里没有提前的准备,很难当场写好。
二,有些问题可能你觉得自己专门准备过,准备好滔滔不绝显示能力。但很多时候面试官不喜欢你说的太多,因为说的太多很像背面经。。。因此首先要简略的回答出重点,再看看面试官的反应如何,考虑是否需要详细的说。
三,针对项目,要准备的非常深,详细到重要参数的大小,为什么这样设置?当然,实际上很多参数其实也没有个明确标准。。。比如我做存储项目的时候,要在一个目录下存储很多大文件,然后文件分片成 256kb。。。面试官就问为什么分片成 256kb,为什么不能分片成 4kb 或者 16kb??当时就懵了,后面网上查了查,发现创作者也是照着其他开源软件的标准设置的。。。
四,总有一些问题你从来就没见过的。遇见不要慌,说回自己熟悉的领域。比如说面试官曾经问我 c++ 里头怎么做 io 隔离?当时想了半天也没弄明白到底什么是 io 隔离,所以我感觉 go 中没有这个问题。于是我就只好说抱歉我不懂 c++,但我可以谈谈 go 语言是如何封装 linux 系统文件系统调用,我们项目是如何处理文件的读写。。。然后在我们这种情况下,应该是不存在 io 隔离的问题等等。最后也顺利过了面试。
最后,希望每个 gopher 都能在求职季收获自己满意的 offer。