GitVP开源文摘
全部文章/后端开发

C/C++学习

C/C++学习,后端开发进阶指南。

作者balloonwj 仓库balloonwj/CppGuide ↗ 星标★ 6,287 字数62,244 阅读1
GitHub 原文 ↗
摘要此仓库是公众号【**CppGuide**】文章汇总,作者是张小方,clone或转发仓库文章时时请保留作者信息。

此仓库是公众号【CppGuide】文章汇总,作者是张小方,clone或转发仓库文章时时请保留作者信息。

CppGuide仓库配有专门的微信维护和交流群,加微信easy_coder备注“cppguide仓库”拉你进群。

在线阅读站点:https://cppguide.cn

在线阅读站点2:https://balloonwj.github.io/cpp-guide-web/

在线阅读站点3: http://balloonwj.gitee.io/cpp-guide-web/

如需下载该站点源码用于自己搭建站点,可以在【CppGuide】微信公众号后台回复关键字“站点下载”即可得到搭建本仓库的站点的全套源码。

Part I

* [如何成为一名合格的C/C++开发者?](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/如何成为一名合格的CC++开发者?.md)
* [不定参数函数实现var_arg系列的宏](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/不定参数函数实现var_arg系列的宏.md)
* [你一定要搞明白的C函数调用方式与栈原理](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/你一定要搞明白的C函数调用方式与栈原理.md)
* [深入理解C/C++中的指针](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/深入理解CC++中的指针.md)
* [详解C++11中的智能指针](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/详解C++11中的智能指针.md)
* [C++17结构化绑定](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/C++17结构化绑定.md)
* [C++必须掌握的pimpl惯用法](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/C++必须掌握的pimpl惯用法.md)
* [用Visual Studio调试Linux程序](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/用VisualStudio调试Linux程序.md)
* [如何使用Visual Studio管理和阅读开源项目代码](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/如何使用VisualStudio管理和阅读开源项目代码.md)
* [利用cmake工具生成Visual Studio工程文件](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/C++必知必会的知识点/利用cmake工具生成VisualStudio工程文件.md)
* [后台C++开发你一定要知道的条件变量](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/多线程/后台C++开发你一定要知道的条件变量.md)
* [整型变量赋值是原子操作吗?](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/多线程/整型变量赋值是原子操作吗?.md)
* [bind 函数重难点解析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/bind函数重难点解析.md)
* [connect 函数在阻塞和非阻塞模式下的行为](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/connect函数在阻塞和非阻塞模式下的行为.md)
* [select 函数重难点解析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/select函数重难点解析.md)
* [Linux epoll 模型(含LT 模式和 ET 模式详解)](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/Linuxepoll模型(含LT模式和ET模式详解).md)
* [socket 的阻塞模式和非阻塞模式](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/socket的阻塞模式和非阻塞模式.md)
* [非阻塞模式下 send 和 recv 函数的返回值](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/非阻塞模式下send和recv函数的返回值.md) 
* [服务器开发通信协议设计介绍](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/服务器开发通信协议设计介绍.md)
* [TCP 协议如何解决粘包、半包问题](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/TCP协议如何解决粘包、半包问题.md)
* [网络通信中收发数据的正确姿势](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/网络通信中收发数据的正确姿势.md)
* [服务器端发数据时,如果对端一直不收,怎么办?](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/网络编程/服务器端发数据时,如果对端一直不收,怎么办?.md)
* [利用telnet命令发电子邮件](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员必知必会的网络命令/利用telnet命令发电子邮件.md)
* [做Java或者C++开发都应该知道的lsof命令](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员必知必会的网络命令/做Java或者C++开发都应该知道的lsof命令.md)
* [Linux网络故障排查的瑞士军刀nc命令](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员必知必会的网络命令/Linux网络故障排查的瑞士军刀.md)
* [Linux tcpdump使用详解](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员必知必会的网络命令/Linuxtcpdump使用介绍.md)
* [从抓包的角度分析connect函数的连接过程](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员必知必会的网络命令/从抓包的角度分析connect函数的连接过程.md)
* [服务器开发中网络数据分析与故障排查经验漫谈](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员必知必会的网络命令/服务器开发中网络数据分析与故障排查经验漫谈.md)

## Part II

* [主线程与工作线程的分工](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/主线程与工作线程的分工.md)
* [Reactor模式](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/Reactor模式.md)
* [实例:一个服务器程序的架构介绍](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/实例:一个服务器程序的架构介绍.md)
* [错误码系统的设计](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/错误码系统的设计.md)
* [日志系统的设计](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/日志系统的设计.md)
* [如何设计断线自动重连机制](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/如何设计断线自动重连机制.md)
* [心跳包机制设计详解](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/心跳包机制设计详解.md)
* [业务数据处理一定要单独开线程吗](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/业务数据处理一定要单独开线程吗.md)
* [C++ 高性能服务器网络框架设计细节](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/高性能服务器框架设计/C++高性能服务器网络框架设计细节.md)
* [从零实现一个http服务器](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/服务器开发案例实战/从零实现一个http服务器.md)
* [从零实现一款12306刷票软件](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/服务器开发案例实战/从零实现一款12306刷票软件.md)
* [从零实现一个邮件收发客户端](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/服务器开发案例实战/从零实现一个邮件收发客户端.md)
* [从零开发一个WebSocket服务器](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/服务器开发案例实战/从零开发一个WebSocket服务器.md)
* [从零学习开源项目系列(一)从一款多人联机实时对战游戏开始](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/服务器开发案例实战/1从一款多人联机实时对战游戏开始.md)
* [从零学习开源项目系列(二)最后一战概况](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/服务器开发案例实战/2最后一战概况.md)
* [从零学习开源项目系列(三) CSBattleMgr服务源码研究](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/服务器开发案例实战/3CSBattleMgr服务源码研究.md)
* [从零学习开源项目系列(四)LogServer源码探究](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/服务器开发案例实战/4LogServer源码探究.md)

Part III

* [01 TeamTalk介绍](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/01TeamTalk介绍.md)
* [02 服务器端的程序的编译与部署](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/02服务器端的程序的编译与部署.md)
* [03 服务器端的程序架构介绍](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/03服务器端的程序架构介绍.md)
* [04 服务器端db_proxy_server源码分析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/04服务器端db_proxy_server源码分析.md)
* [05 服务器端msg_server源码分析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/05服务器端msg_server源码分析.md)
* [06 服务器端login_server源码分析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/06服务器端login_server源码分析.md)
* [07 服务器端msfs源码分析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/07服务器端msfs源码分析.md)
* [08 服务器端file_server源码分析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/08服务器端file_server源码分析.md)
* [09 服务器端route_server源码分析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/09服务器端route_server源码分析.md)
* [10 开放一个TeamTalk测试服务器地址和几个测试账号](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/10开放一个TeamTalk测试服务器地址和几个测试账号.md)
* [11 pc客户端源码分析](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/TeamTalk源码解析/11pc客户端源码分析.md)

libevent源码深度剖析

leveldb源码分析

Memcached源码分析

游戏开发专题

Linux内核专题——心中的内核

大型专栏:Linux网络编程从应用层到内核实现源码详解

大型专栏:Linux网络编程从应用层到内核实现源码详解

第 02 篇 · 工具箱:读源码的工具,和看内核的工具

第 03 篇 · 三层派发总纲:socket 函数如何找到它的内核实现

第 04 篇 · 读内核网络代码的最小 C 知识

第 05 篇 · 谁在和你抢同一个 socket:lock_sock() 到底锁了什么

第 06 篇 · sk_buff(上):一块内存如何同时是帧、是包、是段

第 07 篇 · sk_buff(下):非线性区、克隆与引用计数

第 08 篇 · skb 的分配与回收:一个包的内存从哪来,又还到哪去

第 09 篇 · struct socket  与 struct sock:为什么一个连接要两个结构体

第 10 篇 · sk 的队列与内存记账:SO_SNDBUF 到底限制了什么

第 11 篇 · net_device 与多队列模型:网卡在内核里长什么样

第 12 篇 · network namespace:struct net 为什么无处不在

第 13 篇 ·  socket() :一个 fd 的诞生

第 14 篇 · bind():端口哈希表的秘密

第 15 篇 · listen():两个队列的真相

第 16 篇 · connect():三次握手的发起端

第 17 篇 · accept():从全连接队列取一个 sk

第 18 篇 · 一次连接的完整生命周期:从第一个 SYN 到最后一个 FIN

第 19 篇 ·  send 家族:msghdr 与 iov_iter

第 20 篇 · recv 家族:从接收队列拷贝出来

第 21 篇 · 阻塞与非阻塞:同一段代码,只差一个 timeo

第 22 篇 · setsockopt 的一生:level 派发与 sockptr_t

第 23 篇 · 选项 vs sysctl vs cgroup:谁说了算

第 24 篇 · cmsg:被忽略的另一半接口

第 25 篇 · select/poll/epoll:唤醒链是怎么接起来的

第 26 篇 · epoll 内部:一棵红黑树、一条就绪链、一次 ep_poll()

第 27 篇 · close() 与 shutdown():语义差异的内核根源

第 28 篇 · sendfile/splice/mmap:绕开用户态缓冲区

第 29 篇 · 同步与异步:SIGIO 那一代,和 io_uring 那一代

第 30 篇 · tcp_sendmsg_locked:用户数据如何变成 skb

第 31 篇 · tcp_push 与 Nagle:数据什么时候真的发出去

第 32 篇 · tcp_transmit_skb:TCP 头是怎么拼出来的

第 33 篇 · 八个标志位,各自由谁置上

第 34 篇 · 滑动窗口:四个序号,两个窗口

第 35 篇 · TSO / GSO:一个 skb 怎么变成多个报文

第 36 篇 · ip_queue_xmit:路由查找与 IP 头组装

第 37 篇 · 拥塞控制框架:tcp_congestion_ops 的回调表

第 38 篇 · 重传定时器与 RTO:超时时间怎么算出来,超时之后做什么

第 39 篇 · 快速重传与尾部丢包探测:不等 RTO 就判出丢包的两条路

第 40 篇 · RACK:按发送时间判定丢包

第 41 篇 · undo:判错之后怎么撤销这次拥塞窗口下调

第 42 篇 · 邻居子系统、ARP 与 __dev_queue_xmit

第 43 篇 · 从硬中断到软中断:napi_schedule

第 44 篇 · net_rx_action:软中断主循环与 budget

第 45 篇 · threaded NAPI、napi_config 与 busy_poll

第 46 篇 · 驱动 poll 内部:从 DMA 描述符环到 skb

第 47 篇 · GRO:把小报文粘成大报文

第 48 篇 · __netif_receive_skb_core:packet_type 分发与 RPS/RFS

第 49 篇 · ip_rcv到 ip_local_deliver:IP 层收包的三道关

第 50 篇 ·tcp_v4_do_rcv:socket 查找、backlog 与 prequeue 的消亡

第 51 篇 · tcp_rcv_established:快速路径为什么快

第 52 篇 · tcp_ack 与 SACK 处理:收到一个 ACK,发送方更新哪些状态

第 53 篇 · 乱序队列:一棵红黑树,进树、出树与内存不够时先丢谁

第 54 篇 · 延迟 ACK、QUICKACK 与 ACK 压缩

第 55 篇 · 状态机:握手、挥手、TIME_WAIT 与保活

第 56 篇 · CUBIC 与 BBR 源码对读

第 57 篇 · 接收窗口与内存自动调优

第 58 篇 · 路由子系统与 dst_entry 的生命周期

第 59 篇 · FIB trie、策略路由与 nexthop 对象

第 60 篇 · netfilter:hook 点如何嵌进收发路径

第 61 篇 · TC 与 qdisc:pfifo_fast、fq、fq_codel

第 62 篇 · 桥接与 VLAN:帧进桥之后怎么查表转发,VLAN 标签在哪一步加减

第 63 篇 · 隧道与封装:一个帧被 VXLAN 套上 50 字节,再原样拆开的全程

第 64 篇 · XDP:在 skb 诞生之前拦截

第 65 篇 · eBPF 网络钩子全景

第 66 篇 · 硬件卸载:NETIF_F_*如何改变代码路径

第 67 篇 · BIG TCP:64KB 这道上限卡在哪,内核用什么办法绕过去

第 68 篇 · devmem TCP:数据直落 GPU 内存

第 69 篇 · io_uring 零拷贝收发

第 70 篇 · MPTCP 与 PSP:新协议如何嵌进旧框架

第 71 篇 · dropreason 与观测工具箱:内核怎么给出丢包原因码,你又怎么读到它

第 72 篇 · 性能排查实战:四个现场案例,以及怎么把这套方法用到你自己的内核上

远程控制软件源码

入云龙远程控制系统 · 从零开发

第 1 课 课程导览:你将亲手做出什么

第 2 课 四个角色的分工:用对讲机和快递站打比方

第 3 课 开发环境搭建:把工具一个个装好

第 4 课 工程基本功:CMake 工程怎么搭、编译、调试、出包

第 5 课 立规矩:编码规范与工程纪律

第 6 课 Probe First 准则:先跑测试再改正式代码

第 7 课 master 界面整体设计:先画线框,定四大分区

第 8 课 主控面板首页设计:让人一眼知道能干嘛

第 9 课 设备栏 + 多 tab 工作区:开页、关页、掉线自动关

第 10 课 总体技术方案鸟瞰:每个选型为什么这么选

第 11 课 自己设计一套暗号:二进制协议入门

第 12 课 用 libuv 写最简单的网络程序:异步回调思维

第 13 课 master 骨架:一个窗口,背后挂一个网络线程

第 14 课 agent 骨架:balloonui 自绘窗口

第 15 课 先定 UI 规范:主题 token、Toast 三色、组件库

第 16 课 日志地基:落盘约定 / 滚动 / 级别 / 立即落盘

第 17 课 第一次握手:设备列表里冒出一台真机

第 18 课 控制通道 ControlChannel:把收发收口成一个盒子

第 19 课 心跳与掉线:脉冲状态点与掉线善后

第 20 课 帧的统一收口 FrameCodec

第 21 课 为什么要加密:握手密钥交换(讲人话)

第 22 课 端到端加密落地

第 23 课 信令链路加密:端到服务器那一段

第 24 课 压缩:让数据更小、省流量

第 25 课 PayloadTransform:加密 + 压缩串成一条流水线

第 26 课 三种连法总览:直连 → 打洞 → 中转

第 27 课 信令服务器 rylss(一):注册与查找

第 28 课 信令服务器 rylss(二):撮合与打洞中继

第 29 课 中转服务器 rylsr:按 UUID 配对、字节透传

第 30 课 NAT 与打洞原理

第 31 课 UDP 打洞实战

第 32 课 KCP:让不可靠的 UDP 变可靠

第 33 课 连接状态机:超时、回退与指标上报界面

第 34 课 一键生成 agent:配置塞进 exe 尾部

第 35 课 副连接 AuxiliaryConnection:主通道之外的旁路

第 36 课 连接管理 / 网络监视器(master 这侧)

第 37 课 agent 升级(一):下发新版本

第 38 课 agent 升级(二):自我替换 + 失败回滚

第 39 课 远程拉取三件套:拉日志 / 拉 dump / 拉屏幕截图

第 40 课 权限与授权弹窗 + 审计日志

第 41 课 为什么要插件化

第 42 课 插件 SDK 与宿主:tab 的一生、掉线自动关

第 43 课 写第一个插件 hello-world:两端最小闭环

第 44 课 插件下发机制:自动推、去重、独立副连接

第 45 课 两端的插件 UI 宿主

第 46 课 统一的传输管理设计

第 47 课 远程终端(一):接管命令行

第 48 课 远程终端(二):中断、历史与单会话收口

第 49 课 远程屏幕(一):屏幕采集

第 50 课 远程屏幕(二):编码与推流

第 51 课 远程屏幕(三):master 端解码显示

第 52 课 远程屏幕(四):鼠标键盘输入回传

第 53 课 远程屏幕·业务①:远程桌面

第 54 课 远程屏幕·录屏

第 55 课 远程屏幕·屏幕墙

第 56 课 远程屏幕·屏幕广播

第 57 课 远程屏幕·屏幕中转

第 58 课 文件管理(一):双栏浏览

第 59 课 文件管理(二):传输引擎与队列

第 60 课 远程聊天:文字 / 图片 / 表情 / 历史

第 61 课 语音视频通话(一):音频

第 62 课 语音视频通话(二):视频 + 通话交互

第 63 课 系统管理(一):进程 / 服务 / 启动项 / 网络连接

第 64 课 系统管理(二):注册表 / 事件日志 / 磁盘 / 计划任务

第 65 课 设备管理进阶:分组 / 收藏置顶 / 批量 / 持久化

第 66 课 公告 / 电源启动 / 杂项

第 67 课 打包与部署

Part IV

* [腾讯后台开发实习生技能要求](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员面试题精讲/腾讯后台开发实习生技能要求.md)
* [聊聊如何拿大厂的 offer](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员面试题精讲/聊聊如何拿大厂的offer.md)
* [网络通信题目集锦](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员面试题精讲/网络通信题目集锦.md)
* [我面试后端开发经理的经历](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员面试题精讲/我面试后端开发经理的经历.md)
* [Linux C/C++后端开发面试问哪些问题](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员面试题精讲/LinuxCC++后端开发面试问哪些问题.md)
* [给工作 4 年迷茫的程序员们的一点建议](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/职业规划/给工作4年迷茫的程序员们的一点建议.md)
* [聊聊技术人员的常见的职业问题](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/职业规划/聊聊技术人员的常见的职业问题.md)
* [写给那些傻傻想做服务器开发的朋友](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/职业规划/写给那些傻傻想做服务器开发的朋友.md)
* [2020 年好好读一读开源代码吧](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/自我提升与开源代码/2020年好好读一读开源代码吧.md)
* [后台开发应该读的书](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/后端开发相关的书籍/后台开发应该读的书.md)
* [程序员如何写简历](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员的简历/程序员如何写简历.md)
* [技术面试与HR谈薪资技巧](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员的薪资与年终奖那些事儿/技术面试与HR谈薪资技巧.md)
* [聊一聊程序员如何增加收入](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员的薪资与年终奖那些事儿/聊一聊程序员如何增加收入.md)
* [谈一谈年终奖](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员的薪资与年终奖那些事儿/谈一谈年终奖.md)
* [拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去?](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员的烦心事/拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去?.md)
* [我是一名程序员,结婚时女友要求我用两年的工资作为彩礼,我该不该答应?](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/程序员的烦心事/我是一名程序员,结婚时女友要求我用两年的工资作为彩礼,我该不该答应?.md)
* [我的 2019](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/作者的故事/我的2019.md)
* [我是如何年薪五十万的](https://raw.githubusercontent.com/balloonwj/CppGuide/HEAD/articles/作者的故事/我是如何年薪五十万的.md)

贡献者

你也可以给该仓库提交修改内容,如果你的 PULL Requeset 被采纳,你的名字将会出现在贡献者列表中。
如果您在阅读上述文章的过程中有任何问题或者建议,可以加本仓库微信交流群交流,可以先微信 cppxiaofang,备注“CppGuide”,备注不对不加哦。
仓库中部分文章来源于网络,侵权请联系删除。
本仓库备份QQ交流群:169315117

捐赠与支持

如果你觉得本仓库对你有帮助,想可以请小方喝杯咖啡,扫描下面的任一二维码捐赠即可。

Good Luck and Enjoy it!


如何成为一名合格的CC++开发者?

如何成为一名合格的 C/C++ 开发者?

写在前面的话

在大多数开发或者准开发人员的认识中,C/C++ 是一门非常难的编程语言,很多人知道它的强大,但因为认为“难”造成的恐惧让很多人放弃。

笔者从学生时代开始接触 C/C++,工作以后先后担任过 C++ 客户端和服务器的开发经理并带队开发,至今已经有十多年了。虽然时至今日哪种编程语言对我来说已经不再重要(我目前主要从事 Java 开发),但 C/C++ 仍然是笔者最喜欢的编程语言。在我看来,C/C++ 一旦学成,其妙无穷,就像武侠小说中的“九阳神功”一样,有了这个基础,您可以快速学习任何语言和编程技术。

C/C++ 的当前应用领域

需要注意的是本文不细分 C与 C++ 的区别,通常情况下,C++ 可以看成是 C 的一个超集,在古典时期,可以认为 C++ 就是 C with classes。虽然如今的 C++ 从功能层面上来看,离 C 越来越远了;但是从语法层面来上来看,大多数 C++ 语法还是与 C 基本一致的——所谓 C++ 的面向对象特性,如果细究 C++ 类方法的具体语法还是 C 的过程式语法。当然,面向对象是一种思想,语言本身对其支持的程度固然重要,能否熟练使用更要看开发者的水平。

C 语言目前主要用于像操作系统一类偏底层的应用开发,包括像 Windows/Linux 这样的大型商业操作系统,以及嵌入式操作系统、嵌入式设备上的应用。还有一些开源的软件,也会选择 C 开发,这些系统主要优先考虑程序执行效率和生成的可执行文件的体积(C 代码生成的可执行文件体积相对更小),当然还有一些是历史技术选型问题,这类软件像 Redis、libevent、Nginx,目前像国内的电信服务商所使用的电话呼叫系统,一般也是基于一款叫 FreeSWITCH 的开源 C 程序做的二次开发(项目地址:https://freeswitch.com/ )。

C++ 面向对象的语法与 C 相比较起来,在将高级语言翻译成机器二进制码的时候,C++ 编译器在背后偷偷地做了大量工作,生成了大量的额外机器码,而这种机器码相对于 C 来说不是必须的。例如,对于一个 C++ 类的实例方法,编译器在生成这个方法的机器码时,会将函数的第一个参数设置成对象的 this 指针地址,以此来实现对象与函数的绑定。正因为如此,许多开发者会优化和调整编译器生成的汇编代码。

我们再来说说 C++。C++ 的应用领域目前有三大类,第一类就是我们目前见到的各种桌面应用软件,尤其 Windows 桌面软件,如 QQ、安全类杀毒类软件(如金山的安全卫士,已开源,其代码地址:http://code.ijinshan.com/source/source.html )、各种浏览器等;另外就是一些基础软件和高级语言的运行时环境,如大型数据库软件、Java 虚拟机、C# 的 CLR、Python 编译器和运行时环境等;第三类就是一些业务型应用软件的后台,像游戏的服务器后台,如魔兽世界的服务器(代码地址:https://github.com/azerothcore/azerothcore-wotlk )和一些企业内部的应用系统。笔者曾在某交易所从事后台开发,其交易系统和行情系统就是基于 C++ 开发的。

C++ 与操作系统平台

从上面的介绍可以看出,与 Java、Python 等语言相比,C/C++ 语言是离操作系统最近的一种高级语言,因此其执行效率也比较高。但是有得必有失,因为如此,C/C++ 这门语言存在如下特点。

C/C++ 整套的语法不具备“功能完备性”,单纯地使用这门语言本身提供的功能无法创建任何有意义的程序,必须借助操作系统的 API 接口函数来达到相应的功能。当然,随着 C++ 语言标准和版本的不断更新升级,这种现状正在改变;而像 Java、Python 这类语言,其自带的 SDK 提供了各种操作系统的功能。

举个例子,C/C++ 语言本身不具备网络通信功能,必须使用操作系统提供的网络通信函数(如 Socket 系列函数);而对于 Java 来说,其 JDK 自带的 java.net 和 java.io 等包则提供了完整的网络通信功能。我在读书的时候常常听人说,QQ、360 安全卫士这类软件是用 C/C++ 开发的,但是当我学完整本 C/C++ 教材以后,仍然写不出来一个像样的窗口程序。许多过来人应该都有类似的困惑吧?其原因是一般 C/C++ 的教材不会教你如何使用操作系统 API 函数的内容。

C/C++ 语言需要直接使用操作系统的接口功能,这就造成了 C/C++ 语言繁、难的地方。如操作内存不当容易引起程序宕机,不同操作系统的 API 接口使用习惯和风格也不一样。接口函数种类繁多,开发者如果想开发跨平台的程序,必须要学习多个平台的接口函数和对应的系统原理。

在应用层开发,直接使用操作系统接口的函数,往往执行效率高、控制力度大。而开发能力仅仅限制于操作系统本身,Java 这类语言,很多功能即使操作系统提供了,如果 Java 虚拟机不提供,开发人员也无法使用。正如著名的编程大师 Charles Petzold 所说:

“显而易见,究竟用哪种方式编写应用程序最好,其实并无一定之规。应用程序本身的特性应该是决定采用何种编程工具的最主要因素,但是无论将来你采用什么样的编程工具,通过了解操作系统 API 从而深入理解操作系统的工作原理,这本身就有很重要的意义。操作系统是一个非常复杂的系统,在 API 之上加一层编程语言并不能消除其复杂性,最多不过是把复杂性隐藏起来而已。说不定什么时候,复杂的那一面迟早会蹦出来拖你的后腿,懂得系统 API 能让你到时候可以更快地挣脱困境。 在基本操作系统 API 之上的任何软件层或多或少都会限制你使用操作系统的全部功能。比如,你或许发现采用 Visual Basic 来编写你的应用程序非常理想,但是就有那么一两项非常基本的功能 Visual Basic 无法支持。往往这个时候你得非要调用基本 API 。作为直接使用操作系统 API 的程序员,我们的活动空间完全由 API 来规范,再没有什么其他方式比直接调用 API 更有效、更灵活多样了。”

总结起来,C/C++ 语言的开发核心建立在直接调用操作系统 API 的基础上,优点是执行效率高、发挥空间大;缺点是,需要经过系统深入的学习,学习周期长,编写代码较复杂,容易出错。

Linux C++ 与 Windows C++ 领域之争

我之所以把这个标题单独列出来,是想纠正现在很多 C/C++ 新人和初学者一些不当的认识,一般有以下几种观点:

1. Linux C++ 开发就是后台开发,而 Windows C++ 开发就是客户端开发; 2. 后端开发比客户端开发(前端)高级,因此后端开发行业薪资水平比客户端开发薪资要高; 3. 我只学 Linux,不学 Windows。

我相信对于 80 和 90 的这一代开发者来说,当初接触计算机并进入软件行业,都是从接触 Windows 开始的。时至今日,大数据、人工智能等各种新技术方兴未艾,移动互联网如火如荼。无论是 Linux 还是 Windows,尤其是 Windows,仍然是我们大多数人工作、学习、娱乐使用最多的操作系统——我们每天都会使用其上的各种软件。使用这些软件像喝水、呼吸空气一样自然,所以很多人就忽视了这类软件的 “基础作用”。

Windows 上的软件开发发展了很多年了,这些领域也比较成熟,一般不再招初中级开发,而是需要水平较高、经验较丰富的高级开发者。这让很多人造成了“Windows C++”开发市场需求已经很小了的错觉。试问,QQ PC 部门这些年对外招了多少人?

Linux C++ 和 Windows C++ 一样,没有孰高孰低之分,只是两种不同的操作系统而已,不要觉得在 Linux 下敲命令就比在 Windows 的图形化界面点击鼠标达高级。

图形化界面之于命令行,是人们对更高级、更方便的工具追求的必然结果。Linux C++ 也不一定就是后台开发,Windows C++ 也不一定就是客户端开发;所谓的服务器与客户端是个相对的概念,即谁给谁提供服务,提供服务的我们认为是服务端(后台),被服务的我们认为是客户端(前台)。而 Windows 作为后台服务的应用也比比皆是,如笔者之前所在的某交易所的服务器后台都是 Windows 下的 C++ 程序;另外如一些游戏类的服务器端,也不少是 Windows 的。

借用《UNIX 编程艺术》这本书的观点,Windows 和 Linux 的哲学理念不一样,Windows 是假设你不会操作,它教你如何操作,而 Linux 是假设你会操作然后进行操作。根据这个理念,Windows 一般是普通人用的多,而 Linux 是程序员用的多。

从编程的角度来说,Windows 的代码风格是所谓的匈牙利命名法,而 Linux 是短小精悍的连字符风格。例如同一个表示屏幕尺寸的整型变量,Windows 上可能被命名为 iScreen 或 cxScreen ,而 Linux 可能是 screen;再例如 Windows 上创建线程的函数叫 CreateThread,Linux 下叫 pthread_create。有时候,我觉得 Windows 的匈牙利命名法反而更容易理解代码。

这里既然提到前端(客户端)开发和后端开发,这里不得不提一下,这二者没有优劣之分。其侧重点和开发思维是不一样的,前端(客户端)开发一般有较多的界面逻辑,它们是直接与用户打交道,因而一款客户端软件的好坏很大程度上取决于其界面的易用性和流畅性,开发者只要把这一端的“一亩三分地”给管理好即可;而后端服务,对于普通用户是透明的,开发者的程序必须尽量体现“服务”这个字眼,即更有效地为更多的客户端服务,这就要求兼顾请求响应的正确性、及时性和流畅性。

由于服务软件也是运行在某台物理机器上的程序,鉴于 CPU、内存、网络带宽资源有限,而服务程序一般是长周期运行的,因此必须合理地分配和使用资源(如尽量回收不再使用的各种资源)。开发者应从全局考虑,不能在某个“客户端”这一棵树上“吊死”。

从个人的职业发展来看,建议从事客户端开发的人员适当地了解一下服务器开发的思路,反过来也建议从事后端开发的人员去学习一下客户端开发,二者相得益彰。从个人的技术提高来说,也是很有帮助的。

例如您要学习一套开源的软件代码,如果您熟悉客户端和服务器的基本开发和调试技巧,您可以更好地学习它。而在工作上,一个项目,往往是由客户端和服务器程序组成,如果您都熟悉,您可以站在一个更高的角度去审视它、规划它,这也是架构师的基本要求之一。

最后就是很多读者关心的客户端和服务器的薪资问题,这个没有绝对的谁高谁低,因人而异,因能力而异,因岗位而异。

如何看待 C++ 11/14/17 新标准

C++ 开发者有个不成文的规定:即使您对 C++ 很熟悉,也不要在简历上写上您精通 C++,原因很简单—— C++ 这门语言包含的东西实在太多了,没有人能真正“精通”所有。

C++ 既支持面向对象设计(OOP),也支持以模板语法为代表的泛型编程(GP)。而且新的 C++ 标准和遵循 C++ 新标准的编译器也层出不穷,这些年,C++ 变化越来越大、越来越快,从最初业界和开发者翘首以盼的 C++11 标准,历经 C++14、C++17 到今天的 C++20,这门语言与之前的版本差别越来越大,更多原来需要使用第三库的功能也被陆续添加到 C++ 标准库中。以致于 C++ 之父 Bjarne Stroustrup 也开始对这门语言表示担忧:

“C++11 开始的基础建设尚未完成,而 C++17 在使基础更加稳固、规范性和完整性方面,基本没有做出改善。相反地,却增加了重要接口的复杂度,让人们需要学习的特性数量越来越多。C++ 可能在这种不成熟提议的重压之下崩溃,我们不应该花费大量的时间为专家级用户们(比如我们自己)去创建越来越复杂的东西。(还要考虑普通用户的学习曲线,越复杂的东西越不易普及。)”

文章参看这里:(https://zhuanlan.zhihu.com/p/48793948),在 Bjarne Stroustrup 的信中,他担心 C++ 会像历史的瓦萨号军舰一样,某天新的标准刚启航(发布)便立即沉没。

当然,我们不用有这种担忧,毕竟我们既不是 C++ 标准委员会成员,也不是 C++ 编译器开发厂商。就我个人经验来说,对于C++11、C++14、C++17 乃至 C++20,我们学习它们的准则应该是以实用为主,也就是说我们应该学习其实用的部分,至于新标准提到的一些高级特性和各种复杂的模板,我们大可不必去了解。我们并不是做学术研究,我们学习 C++ 是为了投入实际的生产开发,所以应该去学习 C++ 新标准中实用的语法和工具库。关于 C++11 常用一些知识点,这里也简单地给读者列举一下。

  • auto 关键字
  • for-each 循环
  • 右值及移动构造函数 + std::forward + std::move + stl 容器新增的 emplace_back() 方法
  • std::thread 库、std::chrono 库
  • 智能指针系列(std::shared_ptr/std::unique_ptr/std::weak_ptr),智能指针的实现原理一定要知道,最好是自己实现过
  • 线程库 std::thread + 线程同步技术库 std::mutex/std::condition_variable/std::lock_guard 等
  • Lamda 表达式(Java 中现在也常常考察 Lamda 表达式的作用)
  • std::bind/std::function 库

其他的就是一些关键字的用法(override、final、delete),还有就是一些细节如可以像 Java 一样在类成员变量定义处给出初始化值。

C++ 语言基础与进阶

基础

这里说的基础不是狭义上的 C++ 语言基础,而是包括 C++ 开发这一生态体系的基础,笔者认为的基础包括:

  1. C++ 语言本身熟练使用程度。
  2. 前面也介绍了单纯的 C++ 您啥也干不了,您必须结合一个具体的操作系统平台,所以得熟悉某个操作系统平台的 API 函数,比如Linux,以及该操作系统的原理。这里说的操作系统的原理不局限于您在操作系统原理图书上看的知识,而是实实在在与系统 API 关联起来的,如熟练使用各种进程与线程函数、多线程资源同步函数、文件操作函数、系统时间函数、窗口自绘与操作函数(这点针对 Windows)、内存分配与管理函数、PE 或 ELF 文件的编译、链接原理等等。
  3. 网络通信,网络通信在这里具体一点就是 Socket 编程。这里的 Socket 编程不仅要求熟练使用各种网络 API 函数,还要求理解和灵活运用像三次握手四次挥手等各种基础网络通信协议与原理。关于 Socket 编程实践,《TCP/IP 网络编程》这本书是非常好的入门教材。

说了这么多,您可能会觉得很抽象。笔者在这里举个具体例子。假设我们现在要开发一个类似电驴这样的软件,软件界面如下图:

1542094133679

1542094133679

1542094133679

如上图所示,假设操作系统选择 Windows,使用语言使用 C++,这就要求您必须熟悉 C++ 常用的语法,如果还不熟悉,就需要补充这方面的知识。

电驴的源码可以在公众号【 高性能服务器开发 】后台回复“获取电驴源码”即可获取。

在熟悉 C++ 语法的前提下,从这款产品实现技术来看,我们的目标产品分为 UI 和网络通信部分。下面将详细介绍这两部分。

UI 部分

对于 UI 部分,我们的认识是,这里需要使用 Windows 的窗口技术。可以直接使用原生的 Win 32 API 来制作自己的界面库,也可以选择一些熟悉的界面框架,如 MFC、WTL、Duilib、wxWidgets 等。无论您是在阅读别人的项目还是需要自己开发这样的项目,在确定了这款软件使用的 UI 库(或者使用原生 Win 32 API),您就需要对 Windows 的窗口、对话框、消息产生、派发与处理机制进行了解。同样的道理,如果不熟悉您需要补充相关的知识(关于这一点,下文不再赘述)。

接着,根据上图中的软件功能,大致分为三大模块,即资源、下载和分享。这三大块是可以使用一个 Windows Tab 控件去组织,这个时候您需要了解 Windows Tab 控件的特性。

  • 对于资源模块,本质上是一个窗口中嵌入了一个浏览器控件(WebBrowser 控件),那么您需要了解这一个功能点的相关知识。当用户点击了某个列表中某个具体的资源,可以对其进行下载。这就又涉及到 WebBrowser 控件与 C++ 宿主程序的交互了,那么如何实现呢?可以选择使用 ActiveX 技术,也可以使用 JavaScript 与 C++ 交互技术。
  • 再来看下载模块,当产生一个下载操作时,界面上会产生以下下载列表,每个列表项会实时显示下载进度、下载速率等参数,同时正在下载的项目也可以被暂停、停止和删除。那么这又涉及到 ListView 控件的相关功能,以及 ListView 如何与后台网络通信的逻辑交互。
  • 分享模块是将本地资源分享到服务器或者给其他用户。界面左侧是文件系统的一个快照,那么这又涉及到如何遍历文件系统(了解枚举文件系统的 API),右侧也是一个 ListView 控件,这里不再赘述。
网络通信部分

网络通信部分,主要有两大块,第一个是程序启动时,与服务端的交互;第二个就是文件下载与分享的 P2P 网络。您在阅读或开发的过程中,如果对这些技术比较陌生,您需要补充这些知识,具体的就是 Socket 的各种 API 函数,以及基于这些 API 逻辑的组合。当然可能也会用到操作系统平台所特有的网络 API 函数,如 WSAAsyncSelect 网络模型。

另外一点,网络通信部分如何与 UI 部分进行数据交换,是使用队列?全局变量?或者相应的 Windows 操作平台提供的特殊通信技术,如 PostMessage 函数、管道?如果使用队列,多线程之间如何保持资源的一致性和解决资源竞态,使用 Event、CriticalSection、Mutex、Semaphore 等?

当然,笔者这里只列举了这个软件的主干部分,还有许多方方面面的细节需要考虑。这就需要读者根据自己的情况,斟酌和筛选了。您想达到什么目的,就要去学习和研究相关的代码。

总结起来,可以得到如下公式:

一款 C++ 软件 = C++ 语法 + 操作系统 API 函数调用

进阶

如果您达到了我上面说的三点后,可以再找一些高质量的开源项目去实战一下。需要注意的是,最好找一些没有复杂业务或者您熟悉其业务的开源项目(如开源的 IM 系统)。如果你不熟悉其业务,不仅要学习其业务(软件功能),还需要再去学习它的源码,最后可能让我们迷失了最初学习这款软件的目的。

学习这些项目的同时,读者应该先确定自己的学习目的,如果您的目的是学习和借鉴这款软件的架构,那么先从整体去把握,不要一开始就迷失在细枝末节中,这类我称之为“粗读”;或者您的目的是学习开源软件在一些细节上的处理与做法,这个时候,您可以针对性地去阅读您感兴趣的模块,深入到每一行代码上。

学习开源软件存在一种风气,许多新手喜欢道听途说,一听别人说这个软件不好,那个软件存在某某瑕疵就放弃阅读它的打算了。然后到了实际开发中,因为心中没有任何已有软件开发问题的解决方案,产生挫败感,久而久之就对本来喜欢的 C/C++ 开发失去了兴趣。

学习的过程是先接触,再熟悉,再模仿,再创造。不管什么开源项目,在您心中没有任何思路或者解决方案时,您应该先接触熟悉,不断模仿,做到至少心中有一套对于某场景的解决方案,然后再来谈创新谈批判、改造别人的项目。

我个人学习一套陌生的开源项目时,总是喜欢将程序用调试器正常跑起来,然后再中断下来,统计当前的线程数目,然后通过程序入口 main 函数从主线程追踪其他工作线程是如何创建的;接着,分析和研究每个线程的用途以及线程之间交互的,这就是整体把握,接着找我感兴趣的细节去学习。

这里我以学习 Redis 为例。将 Redis 源码从官网下载下来以后,使用喜欢的代码阅读器进行管理。我这里使用的是 Visual Studio,如下图所示:

1542094133679

在大致了解了 Redis 有哪些代码模块以后,我们把代码拷贝到 Linux 平台,然后编译并使用 GDB 调试器跑起来。如下图所示:

1542094133679

然后按 CTRL+C 将 GDB 中断下来,输入 info threads 查看当前程序的所有线程:

1542094133679

接着挨个使用 thread + 线程编号 和 bt 命令去查看每个线程的上下文调用堆栈:

1542094133679

对照每个线程的上下文堆栈,搞清楚其逻辑,并结合主线程,看看每个线程是在何时启动的,端口在何时启动侦听的,等等。做完这一步,关于 redis-server 的框架也基本清楚了。

接着我们可以选择一个自己感兴趣的命令,搞清楚 redis-cli 与 redis-server 命令的交互流程。

最后,如果对 redis-server 源码中各种数据结构和细节感兴趣,我们可以进一步深入到具体的代码细节。

当然,不熟悉 GDB 的读者看笔者这段操作流程比较困难,这是正常的,说明如果想通过调试去研究 Redis 这一款开源软件,您需要去补充一点 GDB 调试的知识。这就是我上文中所说的,针对性地补缺补差。

C++ 面试

关于 C++ 面试,面试的要求到底是侧重代码量、项目经验,还是侧重操作系统、数据结构这种基础知识?我在知乎上曾经专门写过一篇文章来介绍我曾经的 C++ 面试经历和经验,有兴趣的读者可以点击这里查看(https://www.zhihu.com/question/264198516/answer/341999235)。

关于 C++ 面试常见的面试题,可以参考这里(https://zhuanlan.zhihu.com/p/45668078),这篇文章问题点整理得非常详细,读者可以参考一下。

需要注意的是,不仅仅是 C++ 面试,其他语言开发面试也是一样。如果您是想进入大型互联网公司的应届生,那么您应该优先好好准备算法和数据结构知识以应对面试,这是大型互联网公司面试频率最高的考察范围。至于其他的基础知识,如操作系统原理、网络通信等(作为计算机相关专业的学生,这些应该是您的专业课),如果您已经在平时的学习中掌握得很好,那就不用担心,这类问题一般对于应届生求职不会问得太深;倘若您尚未学得扎实,而春招或秋招又时间临近,没有足够的时间去准备这些,您应该只是尽量去补,实在来不及也没关系,还是应该把重心放在好好准备算法和数据结构等知识上。

对于社会人士参加的 C++ 职位的面试,如果是大型互联网公司,虽然社会招聘问的更多的是项目经验,适当地为一些基础的算法和数据结构知识做一些准备也是非常有用的。举个例子,如果问到二分查找这一类基础算法,如果答不出来未免会让面试官印象不太好,场面也比较尴尬。另外,C++ 是一门讲究深度的编程技能,对于有一定工作年限的面试者,面试官往往会问很多原理性的细节,这就要求广大 C++ 开发者在平常多留心、多积累、多思考技术背后的原理。

对于大多数小型企业,无论是应届生还是社会人士,只要有能力胜任一定的工作即可。一般只要对所面试的公司项目有类似经验或者相关的技术能力,基本上就可以通过面试。大多数小公司在乎的是您来了能不能干活,所以这类公司对实际项目经验和技能要求更高一点。

关于项目经验,许多人可能觉得项目经验一定是自己参与的项目,其实不然,项目经验也可以来源于您阅读和学习其他人的项目代码或者开源软件,只要您能看懂并理解它们,在面试的时候提及到,能条理清晰、自圆其说即可。当然,如果不熟悉或者只是了解些皮毛,切记不可信口开河、胡编乱造甚至张冠李戴。

我曾经面试过一些开发者,看简历项目经验丰富,实际一问的时候,只是把别人的框架或者库拿来包装调用一下,问及其技术原理时,不是顾左右而言他就是说不清道不明模棱两可含糊不清,这一类人往往比不知道还让人讨厌,面试官一般反感这一类面试者所谓的项目经验。

学生与社会人士学习 C++ 方式的区别

作为学生有充裕的时间,建议除了把 C++ 语法学好,系统地多读一点基础的书籍,如操作系统原理、网络编程、数据结构与算法等相关各方各面的经典书籍。

可以参考下这里:

https://mp.weixin.qq.com/s/EjgtX2Wghia7ajn2AugCtw

尽量做到等您毕业走出校园以后,至少熟悉一门编程语言和其相应的开发环境,这就是一个基础扎实、理论清晰、编码能力强的求职者。可惜的是,从现在的各种招聘反馈来看,大多数学生在求职时,对相关开发工具和语言的陌生程度实在让人瞠目结舌,面试官在面试的时候会很纳闷:这位学生大学四年(或者七年)到底是否调试过程序?

社会人士由于已经走上工作岗位,家庭、工作的琐事繁多,没有太多的时间去系统地阅读一些相关基础书籍,如果您当前工作正好是从事 C/C++ 开发,那么请结合您当前的项目来学习,搞清楚项目的体系结构、吸收项目中优秀的实现细节,针对性地补充相关知识,这是进步最快的方式。

但是实际情形中,很多人觉得公司的项目代码又烂又杂,不愿意去研究。这种思想千万不能有的,在您没有自己足够好的能力给公司提供更好的解决方案,请先学习和模仿,我们此时要保持“空杯”心态,公司的代码再烂,它也是公司的商业价值所在;即使是纯粹的业务代码,也有它的可取之处,择其善者而从之,其不善者而改之。尤其是开发者处于一些初中级的开发岗位时,可能接触不到公司核心框架的源码,此时千万不要盲目地去排斥。学业务,补基础,时刻意识清醒自己所需,明白自己想要学的东西。

如果从事的不是 C++ 相关的开发,那么可以挤出一些时间去学习一些开源的代码,在阅读开源代码的过程中,针对性地补缺补差。不建议系统地去看《C++ Primer 中文版》《UNIX 环境高级编程》诸如此类的大部头书籍,实际开发中不需要太多这类书中的细枝末节,阅读这类书往往只会事倍功半,甚至最后因书籍太厚、时间不够,最后坚持不下去,最终放弃。

当然,对于社会人士,当您有一定的时间的时候一定要去补充一些基础的、原理性的东西,千万不要沉溺于“面向搜索引擎编程”或者“面向工资编程”,有些问题虽然当时通过搜索引擎解决了,但如果想在技术或职业上有长足的发展,一定要系统地去读一些经典的、轻量级的书籍(如《C++ 对象模型》)。长期在网上的文章中寻章摘句,只会让您的知识结构碎片化、凌乱化,甚至混乱化。而且互联网上的技术文章质量良莠不齐,有时候也容易对自己形成误导和依赖。总而言之,作为技术开发人员,提高自己技术水平是改变现状、改善生活最直接的途径。

小结

关于 C/C++,暂且就讨论这么多。最后再强调一遍,C++ 是一门讲究深度的语言,其“深度”不是体现在会多少 C++ 语法,而是能够洞察您所写的 C++ 代码背后的系统原理,这是需要长期不断的积累的,没有速成之法。反过来一旦学成,可以快速地学习其他语言和框架。个人觉得,如果自主创业或者想在二三线城市长期发展的读者,C/C++ 应该是优选语言,有了它作为基础,您可以跳出依赖各种环境和框架的窠臼,快速地学习和开发您想要的软件,完成您想要的业务产品。

最后,限于笔者经验水平有限,欢迎读者就文中的观点提出宝贵的建议和意见。如果想获得更多的学习资源或者想与我做进一步交流,可以加我微信 easy_coder 一起交流。

文中提到的电驴的源码可以在公众号【 高性能服务器开发 】后台回复“获取电驴源码”即可获取。

不定参数函数实现var arg系列的宏

不定参数函数实现var_arg系列的宏

电驴的源码日志模块有一个叫 DebugLogError 函数,其签名如下:

//代码位于easyMule-master/src/WorkLayer/Log.h 55行
void DebugLogError(LPCTSTR pszLine, ...);
电驴的源码可以在公众号【 高性能服务器开发 】后台回复“获取电驴源码”即可获取。

这个函数的申明在 Log.h 头文件中,是一个全局函数,其实现代码在 Log.cpp 文件中:

//代码位于easyMule-master/src/WorkLayer/Log.cpp 111行
void DebugLogError(LPCTSTR pszFmt, ...)
{
    va_list argp;
    va_start(argp, pszFmt);
    LogV(LOG_DEBUG | LOG_ERROR, pszFmt, argp);
    va_end(argp);
}

这个函数是一个具有不定参数的函数(也就是参数个数不确定),比如调用这个函数我们可以传入一个参数,也可以传入二个或者三个参数等等:

DebugLogError(L"我喜欢你!");
DebugLogError(L"我喜欢你!", L"你喜欢谁?");
DebugLogError(L"我喜欢你!", L"你喜欢谁?", L"萧雨萌!");

与此类似, C 语言中最熟悉的函数 printf() 和 scanf() 就是能传入不定参数的函数的例子,可是你知道如何编写这样具有不定参数的函数么?

你可以通过这段代码学习到编写方法,奥秘就在DebugLogError()中使用的几个你从来没见过的宏,让我们欢迎它们:

  • va_list
  • va_start
  • va_end

这几个宏是C函数库提供的,位于头文件stdarg.h中。下面我们利用这几个宏自定义一个ShowLove()函数:

#include <stdio.h>
#include <tchar.h>
#include <stdarg.h>
#include <locale.h>

int ShowLove(wchar_t* szFirstSentence, ...)
{

   //用来统计可变参数数量
   int num = 0;

   //第一步:
   //申明一个va_list类型对象ap,用于对参数进行遍历
   va_list ap;
   
   //第二步:
   //使用va_start对变量进行初始化
   //这里需要注意的是:
   //在传统C语言中,va_start把ap中内部指针设置为传递给函数参数的【第一个实参】;
   //而在标准的C中,va_start接受一个额外参数,也就是最后一个【固定参数】的名称,
   //并把ap中的内部指针设置为传递给函数的第一个【可变参数】.
   //所以你在VC++ 6.0和VS2008等高版本的编译器中使用va_start需要注意区别
   //这里使用标准C
   va_start(ap, szFirstSentence);


   //第三步:
   //使用va_arg宏返回实参列表中的下一个参数值,并把ap的内部指针推向下一个参数(如果有的话)
   //必须指定下一个参数的类型。
   //在调用va_start之后第一次调用va_arg将返回第一个可变参数的值
   wprintf(szFirstSentence);


   wchar_t* p = 0;
   while(p = va_arg(ap, wchar_t*))
   {
       wprintf(L"%s", p);
       num ++;
   }

   //第四步:
   //待所有可变参数都读取完毕以后,调用va_end宏对ap和va_list做必要的清理工作
   va_end(ap);
   return num;
}

int main(int argc, char* argv[])
{
   setlocale(LC_ALL, "");

   int z = ShowLoveL"我喜欢你!\n");
   int y = ShowLove(L"我喜欢你!", L"你喜欢谁?\n");
   int l = ShowLove(L"我喜欢你!", L"你喜欢谁?", L"萧雨萌!\n");
   
   printf("可变参数个数依次是:%d\t%d\t%d\n", z, y, l);
   return 0;
}

上述代码的运行结果是:

img

这里顺便补充下,va 的是英文 varied arguments (可变参数)的意思。关于 va_list 等宏的实现原理其实也很容易搞明白,这里不再讲解了。

我们现在来看看函数 DebugLogError():

void DebugLogError(LPCTSTR pszFmt, ...)
{
	va_list argp;
	va_start(argp, pszFmt);	
	LogV(LOG_DEBUG | LOG_ERROR, pszFmt, argp);
	va_end(argp);
}

其他的没什么,就是调用了一个函数叫 LogV(),LogV() 的的声明位于 Log.h 文件中,也是一个全局函数:

void LogV(UINT uFlags, LPCTSTR pszFmt, va_list argp);

其实现代码位于 Log.cpp 文件中:

void LogV(UINT uFlags, LPCTSTR pszFmt, va_list argp)
{
	AddLogTextV(uFlags, DLP_DEFAULT, pszFmt, argp);
}

这里又调用了一个函数 AddLogTextV(),这个函数的也声明在 Log.h 中:

void AddLogTextV(UINT uFlags, EDebugLogPriority dlpPriority, LPCTSTR pszLine, va_list argptr);

其实现代码在 Log.cpp 文件中:

void AddLogTextV(UINT uFlags, EDebugLogPriority dlpPriority, LPCTSTR pszLine, va_list argptr)
{
	ASSERT(pszLine != NULL);

	if ((uFlags & LOG_DEBUG) && !thePrefs.GetVerbose() && dlpPriority >= thePrefs.GetVerboseLogPriority())
		return;	
	//Xman Anti-Leecher-Log
	if ((uFlags & LOG_LEECHER) && !thePrefs.GetAntiLeecherLog())
		return;	
	//Xman end

	TCHAR szLogLine[1000];
	if (_vsntprintf(szLogLine, ARRSIZE(szLogLine), pszLine, argptr) == -1)
		szLogLine[ARRSIZE(szLogLine) - 1] = _T('\0');
	if(CGlobalVariable::m_hListenWnd)
		UINotify(WM_ADD_LOGTEXT, uFlags, (LPARAM)new CString(szLogLine));
	//  Comment UI
	/*if (theApp.emuledlg)
		theApp.emuledlg->AddLogText(uFlags, szLogLine);
	else*/
	/*if(SendMessage(CGlobalVariable::m_hListenWnd, WM_ADD_LOGTEXT, uFlags, (LPARAM)szLogLine)==0)*/
	else
	{
		TRACE(_T("App Log: %s\n"), szLogLine);

		TCHAR szFullLogLine[1060];
		int iLen = _sntprintf(szFullLogLine, ARRSIZE(szFullLogLine), _T("%s: %s\r\n"), CTime::GetCurrentTime().Format(thePrefs.GetDateTimeFormat4Log()), szLogLine);
		if (iLen >= 0)
		{
			//Xman Anti-Leecher-Log //Xman Code Improvement
			if (!((uFlags & LOG_DEBUG) || (uFlags & LOG_LEECHER)))
			{
				if (thePrefs.GetLog2Disk())
					theLog.Log(szFullLogLine, iLen);
			}
			else
			if (thePrefs.GetVerbose()) // && ((uFlags & LOG_DEBUG) || thePrefs.GetFullVerbose()))
			{
				if (thePrefs.GetDebug2Disk())
					theVerboseLog.Log(szFullLogLine, iLen);
			}
			//Xman end
		}
	}
}

我们从源头函数调用来理下思路:

  1. 首先用下列参数调用 DebugLogError():
DebugLogError(L"Unable to load shell32.dll to retrieve the systemfolder locations, using fallbacks");
  1. 然后在上述函数内部又调用:
LogV(LOG_DEBUG | LOG_ERROR,
	 L"Unable to load shell32.dll to retrieve the systemfolder locations, using fallbacks",
     argp);

其中,argp 是函数 DebugLogError() 的内部变量,而 LOG_DEBUG 和 LOG_ERROR 是 Log.h 中定义几个宏,其类型为整形:

// Log message type enumeration
#define	LOG_INFO		0
#define	LOG_WARNING		1
#define	LOG_ERROR		2
#define	LOG_SUCCESS		3
#define	LOGMSGTYPEMASK	0x03

// Log message targets flags
#define	LOG_DEFAULT		0x00
#define	LOG_DEBUG		0x10
#define	LOG_STATUSBAR	0x20
#define	LOG_DONTNOTIFY	0x40
#define LOG_LEECHER		0x80 //Xman Anti-Leecher-Log
  1. 最后调用:
AddLogTextV(LOG_DEBUG | LOG_ERROR,
           DLP_DEFAULT,
           L"Unable to load shell32.dll to retrieve the systemfolder locations, using fallbacks",
           argp);

这个函数的第二个参数类型是一个定义在 Log.h 中的枚举变量 EDebugLogPriority,代表调试的记录级别,其取值如下:

enum EDebugLogPriority{
	DLP_VERYLOW = 0,
	DLP_LOW,
	DLP_DEFAULT,
	DLP_HIGH,
	DLP_VERYHIGH
};

这里提醒一点,由于枚举量 DLP_VERYLOW = 0,所以后面的 DLP_LOW、 DLP_DEFAULT、 DLP_HIGH、 DLP_VERYHIGH 就依次等于1、2、3、4,这是C语言规定的,C语言规定枚举量如果不赋初值,根据前面一个量的值依次递增。

我们来实际看看AddTextLogText()函数的实现代码:

void AddLogTextV(UINT uFlags, EDebugLogPriority dlpPriority, LPCTSTR pszLine, va_list argptr)
{
ASSERT(pszLine != NULL);

if ((uFlags & LOG_DEBUG) && !thePrefs.GetVerbose() && dlpPriority >= thePrefs.GetVerboseLogPriority())
	return;	

//Xman Anti-Leecher-Log
if ((uFlags & LOG_LEECHER) && !thePrefs.GetAntiLeecherLog())
	return;	
//Xman end

首先是一个ASSERT断言,这个断言要求 pszLine (函数第三个参数)不能为空。

接着如果同时满足下列两个条件,则函数返回:

  • 条件1:表达式 ((uFlags & LOG_DEBUG) || (uFlags & LOG_LEECHER)) 为真;
  • 条件2:表达式 !(thePrefs.GetVerbose() && dlpPriority >= thePrefs.GetVerboseLogPriority()) 为真。

我们先看条件1,很多年以前,我对这种按位或运算(|)和按位与运算(&)来组合这些程序中的标志的原理一头雾水,虽然那个时候,我知道这些运算符的含义。

现在就以这两个为例吧:

按位或运算,就是把两个数在二进制层面上按位或,比如二进制数:

11 | 10 = 11

第一个数字高位上 1 与第二个数字高位上的 1 来进行或运算,等于 1,放在高位;

第一个数字低位上 1 与第二个数字低位上的 0 来进行或运算,等于 1,放在低位。

同理,与运算:

11 & 10 = 10

按位与,要求两个数字都是 1 才是 1;而按位或只要有一个是 1 就等于 1,除非两者都是 0,则为 0。

看个复杂的:

11001100 & 10101010 = 10001000

这种做法有个两个好处:

  • 第一,可以将某个位置的上的数字来代表当前的状态,比如电路中 1 代表开,0 代表关。那么我用下面数字 a = 10001000 表示电路开关状态,你会发现电路是开的。

再比如,颜色值 RGB 表示法:CD1298, 我想把其中绿色值单独提取出来,怎么做?

方法:

  GreenValue = 0xCD1298 & 0x001200,

这样就可以做到了。

  • 第二,因为是二进制层次上的操作,所以速度非常快。

我们现在分析下代码:

(uFlags & LOG_DEBUG) || (uFlags & LOG_LEECHER)

先看第一部分:

uFlags & LOG_DEBUG

再结合下面的定义:

// Log message targets flags
#define LOG_DEFAULT      0x00
#define LOG_DEBUG       0x10
#define LOG_STATUSBAR 0x20
#define LOG_DONTNOTIFY   0x40
#define LOG_LEECHER      0x80 //Xman Anti-Leecher-Log

这几个常量定义的数值是有讲究的,不是任何数值都行的。我们将它们都化成二进制:

LOG_DEFAULT      0000 0000
LOG_DEBUG        0001 0000
LOG_STATUSBAR    0010 0000
LOG_DONTNOTIFY   0100 0000
LOG_LEECHER      1000 0000

这样假如 uFlags = 1010 0000,这样我要检测是否设置了LOG_DEBUG,我只要这样做:

Result = uFlags & LOG_DEBUG

计算结果

Result => 0000 0000 => 0

这样 if(RESULT){} 中条件为假;说明我没有设置这个标志位;同理我需要检测是否设置 LOG_STATUSBAR 标志,则执行:

Result = uFlags & LOG_STATUSBAR = 0001 0000

这个数字化为十进制不为 0,所以为真,因此在判断语句里面条件也为真,说明设置了这个标志位。

这是正面检测,反过来我想设置这些标识位,而且可以一次设置多个标志位,比如

uFlags = LOG_STATUSBAR  |  LOG_DONTNOTIFY | LOG_LEECHER = 1110 0000

是不是一目了然?

而且我也可以很方便地从设置好的标志位中去掉某个或某些标识位,比如我想从上面的uFlags值中去掉LOG_DONTNOTIFY 标识,怎么办?这样做就可以了:

uFlags & (~LOG_DONTNOTIFY)

来解释下~符号是二进制层次上求反,将对应位上的 1 改为 0,0 改为 1,那么:

~ LOG_DONTNOTIFY = 1011 1111

然后与 uFlags 或起来等于 1010 0000,你看下是不是刚好把 LOG_DONTNOTIFY 去掉了呀?

这种方法效率高不仅是因为在二进制层次上运算,而且它可以用一个较小的数据类型代表多个信息,对数据的利用程度精准到二进制位。


你一定要搞明白的C函数调用方式与栈原理

你一定要搞明白的C函数调用方式与栈原理

写在前面的话

这绝对不是标题党。而是C/C++开发中你必须要掌握的基础知识,也是高级技术岗位面试中高频题。我真的真的真的希望无论是学生还是广大C/C++开发者,都该掌握此文中介绍的知识。

正文

这篇blog试图讲明当一个c函数被调用时,一个栈帧(stack frame)是如何被建立,又如何被消除的。这些细节跟操作系统平台及编译器的实现有关,下面的描述是针对运行在Intel奔腾芯片上Linux的gcc编译器而言。c语言的标准并没有描述实现的方式,所以,不同的编译器,处理器,操作系统都可能有自己的建立栈帧的方式。

一个典型的栈帧

图1是一个典型的栈帧,图中,栈顶在上,地址空间往下增长。 这是如下一个函数调用时的栈的内容:

int foo(int arg1, int arg2, int arg3); 

并且,foo有两个局部的int变量(4个字节)。在这个简化的场景中,main调用foo,而程序的控制仍在foo中。这里,main是调用者(caller),foo是被调用者(callee)。 ESP被foo使用来指示栈顶。EBP相当于一个“基准指针”。从main传递到foo的参数以及foo本身的局部变量都可以通过这个基准指针为参考,加上偏移量找到。 由于被调用者允许使用EAX,ECX和EDX寄存器,所以如果调用者希望保存这些寄存器的值,就必须在调用子函数之前显式地把他们保存在栈中。另一方面,如果除了上面提到的几个寄存器,被调用者还想使用别的寄存器,比如EBX,ESI和EDI,那么,被调用者就必须在栈中保存这些被额外使用的寄存器,并在调用返回前回复他们。也就是说,如果被调用者只使用约定的EAX,ECX和EDX寄存器,他们由调用者负责保存并回复,但如果被调用这还额外使用了别的寄存器,则必须有他们自己保存并回复这些寄存器的值。 传递给foo的参数被压到栈中,最后一个参数先进栈,所以第一个参数是位于栈顶的。foo中声明的局部变量以及函数执行过程中需要用到的一些临时变量也都存在栈中。 小于等于4个字节的返回值会被保存到EAX中,如果大于4字节,小于8字节,那么EDX也会被用来保存返回值。如果返回值占用的空间还要大,那么调用者会向被调用者传递一个额外的参数,这个额外的参数指向将要保存返回值的地址。用C语言来说,就是函数调用:

x = foo(a, b, c);

被转化为:

foo(&x, a, b, c);

注意,这仅仅在返回值占用大于8个字节时才发生。有的编译器不用EDX保存返回值,所以当返回值大于4个字节时,就用这种转换。 当然,并不是所有函数调用都直接赋值给一个变量,还可能是直接参与到某个表达式的计算中,如:

m = foo(a, b, c) + foo(d, e, f);

有或者作为另外的函数的参数, 如:

fooo(foo(a, b, c), 3);

这些情况下,foo的返回值会被保存在一个临时变量中参加后续的运算,所以,foo(a, b, c)还是可以被转化成foo(&tmp, a, b, c)。

让我们一步步地看一下在c函数调用过程中,一个栈帧是如何建立及消除的。

函数调用前调用者的动作

在我们的例子中,调用者是main,它准备调用函数foo。在函数调用前,main正在用ESP和EBP寄存器指示它自己的栈帧。

首先,main把EAX,ECX和EDX压栈。这是一个可选的步骤,只在这三个寄存器内容需要保留的时候执行此步骤。 接着,main把传递给foo的参数一一进栈,最后的参数最先进栈。例如,我们的函数调用是:

a = foo(12, 15, 18);

相应的汇编语言指令是:

push dword 18
push dword 15
push dword 12

最后,main用call指令调用子函数:

call foo

当call指令执行的时候,EIP指令指针寄存器的内容被压入栈中。因为EIP寄存器是指向main中的下一条指令,所以现在返回地址就在栈顶了。在call指令执行完之后,下一个执行周期将从名为foo的标记处开始。 图2展示了call指令完成后栈的内容。图2及后续图中的粗线指示了函数调用前栈顶的位置。我们将会看到,当整个函数调用过程结束后,栈顶又回到了这个位置。

被调用者在函数调用后的动作

当函数foo,也就是被调用者取得程序的控制权,它必须做3件事:建立它自己的栈帧,为局部变量分配空间,最后,如果需要,保存寄存器EBX,ESI和EDI的值。 首先foo必须建立它自己的栈帧。EBP寄存器现在正指向main的栈帧中的某个位置,这个值必须被保留,因此,EBP进栈。然后ESP的内容赋值给了EBP。这使得函数的参数可以通过对EBP附加一个偏移量得到,而栈寄存器ESP便可以空出来做其他事情。如此一来,几乎所有的c函数都由如下两个指令开始:

push ebp
mov ebp, esp

此时的栈入图3所示。在这个场景中,第一个参数的地址是EBP加8,因为main的EBP和返回地址各在栈中占了4个字节。

​

下一步,foo必须为它的局部变量分配空间,同时,也必须为它可能用到的一些临时变量分配空间。比如,foo中的一些C语句可能包括复杂的表达式,其子表达式的中间值就必须得有地方存放。这些存放中间值的地方同城被称为临时的,因为他们可以为下一个复杂表达式所复用。为说明方便,我们假设我们的foo中有两个int类型(每个4字节)的局部变量,需要额外的12字节的临时存储空间。简单地把栈指针减去20便为这20个字节分配了空间:

sub esp, 20

现在,局部变量和临时存储都可以通过基准指针EBP加偏移量找到了。 最后,如果foo用到EBX,ESI和EDI寄存器,则它f必须在栈里保存它们。结果,现在的栈如图4所示。

​

foo的函数体现在可以执行了。这其中也许有进栈、出栈的动作,栈指针ESP也会上下移动,但EBP是保持不变的。这意味着我们可以一直用[EBP+8]找到第一个参数,而不管在函数中有多少进出栈的动作。 函数foo的执行也许还会调用别的函数,甚至递归地调用foo本身。然而,只要EBP寄存器在这些子调用返回时被恢复,就可以继续用EBP加上偏移量的方式访问实际参数,局部变量和临时存储。

被调用者返回前的动作

在把程序控制权返还给调用者前,被调用者foo必须先把返回值保存在EAX寄存器中。我们前面已经讨论过,当返回值占用多于4个或8个字节时,接收返回值的变量地址会作为一个额外的指针参数被传到函数中,而函数本身就不需要返回值了。这种情况下,被调用者直接通过内存拷贝把返回值直接拷贝到接收地址,从而省去了一次通过栈的中转拷贝。 其次,foo必须恢复EBX,ESI和EDI寄存器的值。如果这些寄存器被修改,正如我们前面所说,我们会在foo执行开始时把它们的原始值压入栈中。如果ESP寄存器指向如图4所示的正确位置,寄存器的原始值就可以出栈并恢复。可见,在foo函数的执行过程中正确地跟踪ESP是多么的重要————也就是说,进栈和出栈操作的次数必须保持平衡。 这两步之后,我们不再需要foo的局部变量和临时存储了,我们可以通过下面的指令消除栈帧:

mov esp, ebp
pop ebp

其结果就是现在栈里的内容跟图2中所示的栈完全一样。现在可以执行返回指令了。从栈里弹出返回地址,赋值给EIP寄存器。栈如图5所示:

i386指令集有一条“leave”指令,它与上面提到的mov和pop指令所作的动作完全相同。所以,C函数通常以这样的指令结束:

leave
ret

调用者在返回后的动作

在程序控制权返回到调用者(也就是我们例子中的main)后,栈如图5所示。这时,传递给foo的参数通常已经不需要了。我们可以把3个参数一起弹出栈,这可以通过把栈指针加12(=3个4字节)实现:

add esp, 12

如果在函数调用前,EAX,ECX和EDX寄存器的值被保存在栈中,调用者main函数现在可以把它们弹出。这个动作之后,栈顶就回到了我们开始整个函数调用过程前的位置,也就是图5中粗线的位置。

看个具体的实例:

这段代码反汇编后,代码是什么呢?

#include <stdio.h>
long test(int a, int b)
{
	a = a + 3;
	b = b + 5;
	return a + b;
}
 
int main(int argc, char* argv[])
{
	printf("%d", test(10,90));
	return 0;
}

先来看一个概貌:

9:   int main(int argc, char* argv[])
10:   {
00401070   push        ebp
00401071   mov         ebp,esp
00401073   sub         esp,40h
00401076   push        ebx
00401077   push        esi
00401078   push        edi
00401079   lea         edi,[ebp-40h]
0040107C   mov         ecx,10h
00401081   mov         eax,0CCCCCCCCh
00401086   rep stos    dword ptr [edi]
11:        printf("%d",test(10,90));
00401088   push        5Ah
0040108A   push        0Ah
0040108C   call        @ILT+0(test) (00401005)
00401091   add         esp,8
00401094   push        eax
00401095   push        offset string "%d" (0042201c)
0040109A   call        printf (004010d0)
0040109F   add         esp,8
12:        return 0;
004010A2   xor         eax,eax
13:   }

下面来解释一下,

开始进入Main函数 esp=0x12FF84 ebp=0x12FFC0 完成椭圆形框起来的部分:

00401070   push        ebp

ebp的值入栈,保存现场(调用现场,从test函数看,如红线所示,即保存的0x12FF80用于从test函数堆栈返回到main函数):

00401071    mov        ebp,esp 

此时ebp=0x12FF80 此时ebp就是“当前函数堆栈”的基址 以便访问堆栈中的信息;还有就是从当前函数栈顶返回到栈底:

00401073     sub        esp,40h   

函数使用的堆栈,默认64个字节,堆栈上就是16个横条(密集线部分)此时esp=0x12FF40。 在上图中,上面密集线是test函数堆栈空间,下面是Main的堆栈空间(补充,其实这个就叫做 Stack Frame):

00401076   push        ebx
00401077   push        esi
00401078   push        edi    入栈 
00401079   lea         edi,[ebp-40h]
0040107C   mov         ecx,10h
00401081   mov         eax,0CCCCCCCCh
00401086   rep stos    dword ptr [edi]      

初始化用于该函数的栈空间为0XCCCCCCCC,即从0x12FF40~0x12FF80所有的值均为0xCCCCCCCC:

11:        printf("%d",test(10,90));
00401088   push        5Ah    参数入栈 从右至左 先90  后10
0040108A   push        0Ah 
0040108C   call        @ILT+0(test) (00401005)    

函数调用,转向eip 00401005 。 注意,此时仍入栈,入栈的是call test 指令下一条指令的地址00401091下一条指令是add esp,8。 @ILT+0(?test@@YAJHH@Z):

00401005   jmp       test (00401020)   

即转向被调函数test:

 2:    long test(int a,int b)
 3:    {
00401020   push        ebp
00401021   mov         ebp,esp           
00401023   sub         esp,40h
00401026   push        ebx
00401027   push        esi
00401028   push        edi
00401029   lea         edi,[ebp-40h]
0040102C   mov         ecx,10h
00401031   mov         eax,0CCCCCCCCh
00401036   rep stos    dword ptr [edi]       //这些和上面一样
4:        a = a + 3;                                    
00401038   mov         eax,dword ptr [ebp+8] //ebp=0x12FF24 加8 [0x12FF30]即取到了参数10
0040103B   add         eax,3
0040103E   mov         dword ptr [ebp+8],eax
5:        b = b + 5;
00401041   mov         ecx,dword ptr [ebp+0Ch]
00401044   add         ecx,5
00401047   mov         dword ptr [ebp+0Ch],ecx
6:        return a + b;
0040104A   mov         eax,dword ptr [ebp+8]
0040104D   add         eax,dword ptr [ebp+0Ch]  //最后的结果保存在eax, 结果得以返回
7:   }
00401050   pop         edi                 
00401051   pop         esi
00401052   pop         ebx
00401053   mov         esp,ebp    //esp指向0x12FF24, test函数的堆栈空间被放弃,从当前函数栈顶返回到栈底
00401055   pop         ebp        //此时ebp=0x12FF80, 恢复现场  esp=0x12FF28
00401056   ret                    //ret负责栈顶0x12FF28之值00401091弹出到指令寄存器中,esp=0x12FF30

因为win32汇编一般用eax返回结果 所以如果最终结果不是在eax里面的话 还要把它放到eax。

注意,从被调函数返回时,是弹出EBP,恢复堆栈到函数调用前的地址,弹出返回地址到EIP以继续执行程序。

从test函数返回,执行:

00401091   add         esp,8       

清栈,清除两个压栈的参数10 90 调用者main负责。 (所谓__cdecl调用由调用者负责恢复栈,调用者负责清理的只是入栈的参数,test函数自己的堆栈空间自己返回时自己已经清除,靠!一直理解错)

00401094   push        eax          //入栈,计算结果108入栈,即printf函数的参数之一入栈
00401095   push        offset string "%d" (0042201c)//入栈,参数 "%d"  当然其实是%d的地址
0040109A   call        printf (004010d0)//函数调用 printf("%d",108) 因为printf函数时
0040109F   add         esp,8       //清栈,清除参数 ("%d", 108)
19:        return 0;           
004010A2   xor         eax,eax     //eax清零
20:   }     

main函数执行完毕 此时esp=0x12FF34 ebp=0x12FF80:

004010A4   pop         edi
004010A5   pop         esi
004010A6   pop         ebx
004010A7   add         esp,40h    //为啥不用mov esp, ebp? 是为了下面的比较
004010AA   cmp         ebp,esp   //比较,若不同则调用chkesp抛出异常
004010AC   call        __chkesp (00401150)   
004010B1   mov         esp,ebp   
004010B3   pop         ebp          //ESP=0X12FF84  

EBP=0x12FFC0 尘归尘 土归土 一切都恢复最初的平静了 :)

004010B4   ret

另:

  1. 如果函数调用方式是__stdcall不同之处在于main函数call 后面没有了add esp, 8;test函数最后一句是 ret 8 (由test函数清栈, ret 8意思是执行ret后,esp+8)。
  2. 运行过程中0x12FF28 保存了指令地址 00401091是怎么保存的?栈每个空间保存4个字节(粒度4字节) 例如下一个栈空间0x12FF2C保存参数10,因此:
0x12FF28 0x12FF29 0x12FF2A 0x12FF2B   
   91       10       40       00       

little-endian 认为其读的第一个字节为最小的那位上的数。

  1. char a[] = "abcde"
对局部字符数组变量(栈变量)赋值,是利用寄存器从全局数据内存区把字符串“abcde”拷贝到栈内存中的。
  1. int szNum[5] = { 1, 2, 3, 4, 5 }; 栈中是如何分布的?
00401798   mov         dword ptr [ebp-14h],1
0040179F   mov         dword ptr [ebp-10h],2
004017A6   mov         dword ptr [ebp-0Ch],3
004017AD   mov         dword ptr [ebp-8],4
004017B4   mov         dword ptr [ebp-4],5

可以看出来是从右边开始入栈,所以是 5 4 3 2 1 入栈,

int *ptrA = (int*)(&szNum+1);
int *ptrB = (int*)((int)szNum + 1);
std::cout<< ptrA[-1] << *ptrB << std::endl;

结果如何?

28:       int *ptrA = (int*)(&szNum+1);
004017BB   lea         eax,[ebp]
004017BE   mov         dword ptr [ebp-18h],eax

&szNum是指向数组指针;加1是加一个数组宽度;&szNum+1指向移动5个int单位之后的那个地方, 就是把EBP的地址赋给指针; ptrA[-1]是回退一个int*宽度,即ebp-4;

29:       int *ptrB = (int*)((int)szNum + 1);
004017C1   lea         ecx,[ebp-13h]
004017C4   mov         dword ptr [ebp-1Ch],ecx

如果上面是指针算术,那这里就是地址算术,只是首地址+1个字节的offset,即ebp-13h给指针。实际保存是这样的:

01 00 00 00 02 00 00 00
ebp-14h ebp-13h ebp-10h

注意,是int*类型的,最后获得的是 00 00 00 02,由于Little-endian, 实际上逻辑数是02000000,转换为十进制数就为33554432,最后输出533554432。


深入理解CC++中的指针

深入理解C/C++中的指针

C和C++中最强大的功能莫过于指针了(pointer),但是对于大多数人尤其是新手来说,指针是一个最容易出错、也最难掌握的概念了。本文将从指针的方方面面来讲述指针的概念和用法,希望对大家有所帮助。

内存模型

为了更好地理解指针,让我们来看一下计算机的内存模型。

内存分为物理内存和虚拟内存,物理内存对应计算机中的内存条,虚拟内存是操作系统内存管理系统假象出来的。由于这些不是我们本文的重点,下面不做区分。有不清楚这些概念的同学,可以给我留言或者在线询问。

在不考虑cpu缓存的情况下,计算机运行程序本质上就是对内存中的数据的操作,通俗地来说,就是将内存条某些部分的数据搬进搬出或者搬来搬去,其中“搬进搬出”是指将内存中的二进制数据搬入cpu寄存器及运算器中进行相应的加减运算或者将寄存器中的数据搬回内存单元中,而“搬来搬去”是指将内存中的数据由这个位置搬到另外一个位置(当然,一般不是直接搬,而是借助寄存器作为中间存储区)。如下图所示:

计算机为了方便管理内存,将内存的每个单元用一个数字编号,如下图所以:

图中所示,是一个大小为128个字节的内存空间,其中每一个空格代表一个字节,所以内存编号是0~127。

对于一个32位的操作系统来说,内存空间中每一个字节的编号是一个32位二进制数,所以内存编号从0000 0000 0000 0000 0000 0000 0000 0000至1111 1111 1111 1111 1111 1111 1111 1111,转换成16进制也就是0x00000000至0xFFFFFFFF,由于是从0开始的,所以化成10机制就是从0至2的32次方减1;对于64位操作系统,内存编号也就是从64个0至64个1。

大家需要注意的是,从上面两个图我们可以发现,我们一般将编号小的内存单元画在上面,编号大的画在下面,也就是说从上至下,内存编号越来越大。

指针与指针变量

指针的本意就是内存地址,我们可以通俗地理解成内存编号,既然计算机通过编号来操作内存单元,这就造就了指针的高效率。

那么什么是指针变量呢?指针变量可通俗地理解成存储指针的变量,也就是存储内存地址(内存编号)的变量。首先指针变量和整型变量、字符型变量以及其他数据类型的变量一样都是变量类型;但是,反过来,我们不应该按这样的方式来分类,即:整型指针变量、字符型指针变量、浮点型指针变量等等。为什么不推荐这样的分类方法呢?首先,指针变量就是一个数据类型,指针数据类型,这种数据类型首先是一个变量数据类型,那么它的大小是多少呢?很多同学理所当然地认为整型指针变量和一个字符指针变量的大小是不一样的,这种认识是错的。指针变量也是一个变量,它是一个用来存储其他变量的内存地址的,更准确地说,指针变量时用来存储其他变量的内存首地址的,因为不同的数据类型所占的内存大小不一样。举个例子,在32位机器上,假如a是int型变量,pa是指向a的指针变量,b是一个double型变量,pb是指向b的指针变量,那么a在内存中占四个字节,b在内存中占8个字节,假如a在内存中分布是从0x11111110~0x11111113,而b在内存中分布是0x11112221至0x11112228,那么指针变量pa中存储的内容是0x11111110,而pb中存储就是0x11112221,看到了吧,也就是说,pa和pb中存储的都是地址,而且都是32位的二进制地址;再者,因为存储这样的地址需要4个字节,所以无论是int型指针变量pa或者是double型指针变量pb,它们所占的内存大小都是四个字节,从这点来说,不管什么类型的指针都是一样的,所以不论按整型指针变量、字符型指针变量、浮点型指针变量等等来区分指针变量。总结起来,指针变量和int、float、char等类型一样同属变量类型,指针变量类型占四个字节(32位机器下),存储的是32位的内存地址。下面的代码证明这一点:

上面介绍的是指针变量的一个方面,指针变量还有另外一层含义:在C/C++中星号(\)被定义成取内容符号,虽然所有指针变量占的内存大小和存储的内存地址大小都是一样的,但是由于存储的只是数据的内存首地址,所以指针变量存储的内存地址所指向的数据类型决定着如何解析这个首地址,也就是说对于int型指针变量,我们需要从该指针变量存储的(首)地址开始向后一直搜索4个字节的内存空间,以图中的变量a为例就是从0x12ff60~0x12ff63,对于变量b就是0x12ff44~0x12ff4b。所以从这个意义来上讲,当我们使用\pa,必须先知道pa是一个整型的指针,这里强调“整型”,而a的值1也就存储在从0x12ff60~0x12ff63这四个字节里面,当我们使用\pb,必须先知道pb是一个double型指针,这里强调"double",也就是说值2.0000存储在0x12ff44~0x12ff4b这八个字节里面。因此,我们对指针变量进行算术运算,比如pa + 2,pb + +之类的操作,是以数据类型大小为单位的,也就是说pa + 2,相当于0x12ff60 + sizeof(int) \ 2 = 0x12ff60 + 4 \ 2 = 0x12ff68,不是0x12ff60 + 2哦;而pb - -相当于0x12ff44 + sizeof(double) \ 1 = 0x12ff44 + 8 \* 1 = 0x12ff4c。理解这一点很重要。 同理&a + 2和&b - 1也是一样(注意由于&b是一个指针常量,所以写成&b - -是错误的)。

指针变量和指针常量

指针变量首先是一个变量,由于指针变量存储了某个变量的内存首地址,我们通常认为”指针变量指向了该变量“,但是在这个时刻指针变量pa指向变量a,下个时候可能不存储变量a的首地址,而是存储变量c的首地址,那么我们可以认为这个时候,pa不再指向a,而是指向c。请别嫌我啰嗦,为了帮助你理解,我是故意说得这么细的,后面我们讨论高级主题的时候,当你觉得迷糊,请回来反复咀嚼一下这段话。也就是说指针变量是一个变量,它的值可以变动的。

相反,指针常量可通俗地理解为存储固定的内存单元地址编号的”量“,它一旦存储了某个内存地址以后,不可再改存储其他的内存地址了。所以指针常量是坚韧,因为它”咬定青山不放松“;说是”痴情“,因为它”曾经沧海难为水“。我这里讲的指针常量对应的是const关键字定义的量,而不是指针字面量。像&a, &b, &a + 2等是指针字面量,而const int \*p = &a;中的p才算是真正的指针常量,指针常量一般用在函数的参数中,表示该函数不可改变实参的内容。来看一个例子吧:

上面的函数由于修改了一个常指针(多数情况下等同指针常量),因而会编译出错:error C3892: “x”: 不能给常量赋值。

指针变量与数组

记得多年以前,我在学生会给电子技术部和地理信息系统专业的同学进行C语言培训时,这是一个最让他们头疼和感到一头雾水的话题,尤其是指针变量与二维数组的结合,我永远忘不了胡永月那一脸迷惑与无助的表情。今天我这里给大家深入地分析一下。先看一个例子:

如果你能得出下面这样的结果,说明你已经基本上对数组与指针的概念理解清楚了:

通过上图,我们可以知道\(a + 1) = 2, \(ptr - 1) = 5。

且不说很多同学根本得不到这样的结果,他们看到int ptr = (int)(&a+1);这样的语句就已经懵了,首先,我们知道C语言中规定数组名表示这个数组的首地址,而这里竟然出现了&a这样的符号,本来a就是一个指针常量了,这里对&a再次取地址难道不是非法操作吗?哈哈,当你有这样的疑问的时候,说明你对二维数组相关知识理解不深入。我这里先给你补充下知识点吧:

看这样一个二维数组:int arr\[3][4],这个数组布局如下:

这是一个3行4列的数组,它在内存中的分布如下:

这里每一个数组元素占4字节空间,我们知道C语言规定,数组名arr是整个数组元素的首地址,比如是0x0012ff08,而像arr[0]、arr[1]、arr[2]分别是数组第一行、第二行、第三行的首地址,也就是0x0012ff08、0x0012ff18、0x0012ff28。

我们把arr、arr[0]和&arr\[0][0]单独拿出来分析,因为数组的首地址也是第一列的首地址,同时也是第一个元素的首地址,所以arr和arr[0]和&arr\[0][0]表示的都是同一个地址,但是这三个首地址在进行算术运算时是有区别的。如果&arr\[0][0] + 1,这里的相当于跳一个元素的内存字节数,也就是4个;但是arr[0] + 1,移动的内存字节数是一列元素所占的字节数,也就是4 * 4 = 16个;最后,也是最让人迷惑的的就是arr + 1,这个时候移动的内存数目是整个数组占的内存字节数,也就是48个字节数,所以a + 1所表示的内存地址已经不属于这个数组了,这个地址位于数组最后一个元素所占内存空间的下一个字节空间。

光有这些知识还是不能解决上面的问题,我们再补充一个知识点。

C++是一种强类型的语言,其中有一种类型叫void类型,从本质上说void不是一种类型,因为变量都是”有类型“的,就好像人的性别,不是男人就是女人,不存在无性别的人,所以void更多是一种抽象。在程序中,void类型更多是用来”修饰“和”限制“一个函数的:例如一个函数如果不返回任何类型的值,可以用void作返回类型;如果一个函数无参数列表,可以用void作为参数列表。

跟void类型”修饰“作用不同,void型指针作为指向抽象数据的指针,它本质上表示一段内存块。如果两个指针类型不同,在进行指针类型赋值时必须进行强制类型转换,看下面的例子:

但是可以将任何指针类型赋值给void类型而无须进行强制类型转换:

当然,如果把void型指针转换成并不是它实际指向的数据类型,其结果是不可预测的。试想,如果把一个int型指针赋给void型,然后再把这个void型指针强制转换成double型指针,这样的结果是不可预测的。因为不同数据类型所占内存大小不一样,这样做可能或截断内存数据或者会增加一些未知的额外数据。所以,最好是将void类型指针转换成它实际数据类型指针。

有了上面的说明,你应该能看懂C函数库中下面这个函数的签名含义了吧?

 void *memcpy(void *dest,const void *src,size_t len);

在这里,任何数据类型的指针都可以传给这个函数,所以这个函数成为了一个通用的内存复制函数。

好了,说了这么多,回答最初的那个问题上:

我们来分析一下。首先,我们可以将这个数组看成是一个特殊的二维数组,也就是1行5列的二维数组,现在a表示的是第一个元素的首地址,那么a + 1指向的就是下一个元素的内存首地址,所以\(a + 1) = 2;而&a则是表示整个数组的首地址,那么&a + 1移动的内存数目就是整个数组所占字节数,假如这里我们量化来说明,假如原先数组中第一个元素的首地址是1,那么&a + 1表示的就是21,而这个地址已经不属于数组了,接着通过(int\)(&a + 1)将数组指针转换成整型指针,这样原先&a + 1表示的数据范围是21~40一下缩小到21~24,正好是一个int型的大小,所以ptr - 1的存储的地址就是17了,表示的数据内存范围是17~20,这样\*(ptr - 1)正好就是最后一个元素5了。

但是话说回来,首先这样的转换安全与否尚有争议,再次,这样的程序晦涩难懂,难于理解,所以建议不要写出这样的程序。

上面的例子,只是通过一些简单的数据类型来说明内存分布,但是实际对于一些复杂的数据类型,尤其是一些自定义的类或者结构体类型,内存分布必须还要充分考虑到字节对齐。比如下面的代码:

这是输出结果:

由于结构体s1中存在字节对齐现象(以sizeof(double) = 8个字节对齐),所以s1占据24字节内存,而s2只占16个字节。知道这点,我们平常在设计结构体字段的时候,就可以合理安排字段顺序来使用更少的内存空间了。

函数指针

函数指针是指向函数的指针变量。 因而“函数指针”本身首先应是指针变量,只不过该指针变量指向函数。这正如用指针变量可指向整型变量、字符型、数组一样,这里是指向函数。C/C++程序在编译时,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样,在这些概念上是一致的。函数指针有两个用途:调用函数和做函数的参数。

我们先来先使用函数指针调用函数。如下图所示:

上面的代码首先是定义了一个函数f,然后是定义一个函数指针pf,接着在主函数里面将函数f的地址赋值给函数指针,这样pf就指向了函数f,这样使用*pf就可以直接调用函数了。但是上面的例子定义函数指针的方法在某些编译器中是无法通过的,最好通过typedef关键字定义函数指针,推荐的写法如下:

通过上面的例子,我们来总结下函数指针的定义和使用方法:

首先,通过typedef关键字定义一个函数指针类型,然后定义一个该函数指针类型变量,接着将函数的入口地址赋值给该函数指针类型变量,这样就可以通过这个函数指针变量调用函数了。

需要注意的是,定义函数指针类型时的函数签名(包括函数返回值和函数参数列表的类型、个数、顺序)要将赋值给该类型变量的函数签名保持一致,不然可能会发生很多无法预料的情况。还有一点,就是C/C++规定函数名就表示函数入口地址,所以,函数名赋值时函数名前面加不加取地址符&都一样,也就是说PF pf = f等价于PF pf = &f。这个&是可以省略的。但是这是单个函数的情况,在C++中取类的方法函数的地址时,这个&符号式不能省略的,见下面的例子:

函数指针的另外一个用处,而且是用的最多的,就是作为一个函数的参数。也就是说某个函数的某个参数类型是一个函数,这在windows编程中作为回调函数(callback)尤其常见。我们来看一个例子:

上图中,函数f2第一个参数类型是一个函数,我们传入函数f1作为参数。这种函数参数是函数类型的用法很重要,建议大家掌握。

指针变量的定义方法

先插播一段广告,说下main函数的返回值问题,如下图:

这种main函数无返回值的写法,在国内各大C/C++教材上屡见不鲜,这种写法是错误的!

有一点你必须明确:C/C++标准中从来没有定义过void main()这样的代码形式。C++之父Bjarne Stroustrup在他的主页FAQ中明确地写了这样一句话:

在C++中绝对没有出现过void main(){ / ... / } 这样的函数定义,在C语言中也是。

main函数的返回值应该定义为int型,在C/C++标准中都是这样规定的。在C99标准规定,只有以下两种定义方式是正确的的:

1 int main(void);
2 int main(int argc,char *argv[]);

虽然在C和C++标准中并不支持void main(),但是在部分编译器中void main()依旧是可以通过编译并执行的,比如微软的VC++。由于微软产品的市场占有率和影响力很大,因为在某种程度上加剧了这种不良习惯的蔓延。不过,并非所有犯人编译器都支持void main(),gcc就站在VC++的对立面,它是这一不良习气的坚定抵制者,它会在编译时明确地给出一个错误。

广告播完,我们回到正题上来。我们来看下如何定义一个指针,首先看一个例子:

我来替你回答吧,你肯定认为a是一个指针变量,b是一个整型变量,c和d都是一个指针变量。好吧,恭喜你,答错了!

其实定义指针变量的时候,星号(\*)无论是与数据类型结合还是与变量名结合在一起都是一样的!但是,为了便于理解,还是推荐大家写成第一种形式,第二种形式容易误导人,不是吗?而且第一种形式还有一个好处,我们可以这样看:

int *a;  //将*a看成一个整体,它是一个int型数据,那么a自然就是指向*a的指针了。

说完定义指针的方法,下面我们来看下如何初始化一个指针变量,看下面的代码:

上面的代码有错误吗?

错误在于我们不能这样写:int \*p = 1; 由于p是一个匿名指针,也就是说p没有正确的初始化,它可能指向一个不确定的内存地址,而这个内存地址可能是系统程序内存所在,我们将数值1装入那个不确定的内存单元中是很危险的,因为可能会破坏系统那个内存原来的数据,引发异常。换另一个方面来看,将整型数值1直接赋值给指针型变量p是非法的。

这样的指针我们称为匿名指针或者野指针。和其他变量类型一样,为了防止发生意料之外的错误,我们应该给新定义的指针变量一个初始值。但是有时候,我们又没有合适的初始值给这个指针,怎么办?我们可以使用NULL关键字或者C++中的nullptr。代码如下:

通过上面的写法就告诉编译器,这两个指针现在不会指向不确定的内存单元了,但是目前暂时不需要使用它们。 

C++中的引用

C++中不仅有指针的概念,而且还存在一个引用的概念,看下面的代码:

我开始在接触这个概念的时候,老是弄错。当时这么想的,既然b是a的引用,那么&b应该等于a吧?也就是说,在需要使用变量a的时候,可以使用&b来代替。

上面的这种认识是错误的!所谓引用,使用另外一个变量名来代表某一块内存,也就是说a和b完全是一样,所以任何地方,可以使用a的,换成b也可以,而不是使用&b,这就相当于同一个人有不同的名字,但是不管哪个名字,指的都是同一个人。

新手在刚接触引用的使用,还有一个地方容易出错,就是忘记给引用及时初始化,注意这里的“及时”两个字,C++规定,定义一个引用时,必须马上初始化。看下面的代码:

传值还是传引用(by value or by reference)

看下面的伪代码:

在涉及到利用一个已有初值的变量给另外一个变量赋值时,必须考虑这样的情况。图中变量a已经有了初值,然后利用a来给b赋初值,那么最后改变b的值,a的值会不会受影响呢?这就取决于b到底是a的副本还是和a同时指向同一内存区域,这就是我们常说的赋值时是传值还是传引用。各大语言都是这样规定的,也就是说不局限于C/C++,同时Java、C#、php、javascript等都一样:

如果变量类型是基元数据类型(基础数据类型),比如int、float、bool、char等小数据类型被称为基元数据类型(primitive data type),那么赋值时传的是值。也就是说,这个时候b的值是a的拷贝,那么更改b不会影响到a,同理更改a也不会影响到b。
但是,如果变量类型是复杂数据类型(complex data type),比如数组、类对象,那么赋值时传的就是引用,这个时候,a和b指向的都是同一块内存区域,那么无论更改a或者b都会相互影响。

让我们来深入地分析下,为什么各大语言都采取这种机制。对于那些基元数据类型,由于数据本身占用的内存空间就小,这样复制起来不仅速度快,即使这样的变量数目很多,总共也不会占多大空间。但是对于复杂数据类型,比如一些类对象,它们包含的属性字段就很多,占用的空间就大,如果赋值时,也是复制数据,那么一个两个对象还好,一旦多一点比如10个、100个,会占很大的内存单元的,这就导致效率的下降。

最后,提醒一点,在利用C++中拷贝构造函数复制对象时需要注意,基元数据类型可以直接复制,但是对于引用类型数据,我们需要自己实现引用型数据的真正复制。

C/C++中的new关键字与Java、C#中的关键字对比

我大学毕业的时候痴迷于于网页游戏开发,使用的语言是flash平台的actionscript 3.0(简称as3,唉,如今已经没落),我刚开始由as3转行至C/C++,对于C/C++中new出来的对象必须通过指针对象来引用它非常不习惯。上图中,Object是一个类(class),在Java或者C#等语言中,通过new关键字定义一个对象,直接得到Object的实例,也就是说后续引用这个对象,我们可以直接使用obj.property或者obj.method()等形式,但是在C++中不行,比如用一个指针去接受这个new出来的对象,我们引用这个对象必须使用指针引用运算符->,也就是我们需要这样写:pObj->property或pObject->method()。代码如下:

当然C++中还有一种不需要使用指针就可以实例化出来类对象的方法,从Java、C#等转向C++的程序员容易误解为未初始化对象变量的定义,看下列代码:

这是C++中利用Object类实例化两个对象obj1和obj2,obj2因为调用构造函数传了两个参数param1,param2还好理解一点,对于obj1很多Java或者C#的程序员开始很难接受这种写法,因为如果放在Java或者C#中,obj1根本就没有被实例化嘛,在他们看来,obj1只是一个简单的类型申明。希望Java、C#等程序员要转换过思维来看待C++中的这种写法。

还有一点也容易出错,在C++中,this关键字是一个指针,而不是像在Java、C#中是一个类实例。也就是说,在C++中*this才等价于Java、C#中的this。所以写法也就不一样了:

Windows编程中的指针

Windows是操作系统是用C语言写出来的,所以尽管你在Windows中看到很多不认识的数据类型,但是这些数据类型也是通过基本的C语言类型组装起来的。我们这里只介绍Windows中指针型数据。

定义指针数据类型必须使用星号(\*),但是Windows为了开发的方便,通过宏定义将指针“隐藏起来”,严格地说应该是将星号隐藏起来了,下面给出一些例子:

C++中的智能指针

为了保持内容的完整性,暂且列一个标题放在这里,这个话题请参考本专题相关文章。

我能想到的关于C/C++中指针的内容就这么多了,希望本文对你有用。文中如果有不当或者纰漏的地方欢迎批评指正。


详解C++11中的智能指针

详解 C++ 11 中的智能指针

C/C++ 语言最为人所诟病的特性之一就是存在内存泄露问题,因此后来的大多数语言都提供了内置内存分配与释放功能,有的甚至干脆对语言的使用者屏蔽了内存指针这一概念。这里不置贬褒,手动分配内存与手动释放内存有利也有弊,自动分配内存和自动释放内存亦如此,这是两种不同的设计哲学。有人认为,内存如此重要的东西怎么能放心交给用户去管理呢?而另外一些人则认为,内存如此重要的东西怎么能放心交给系统去管理呢?在 C/C++ 语言中,内存泄露的问题一直困扰着广大的开发者,因此各类库和工具的一直在努力尝试各种方法去检测和避免内存泄露,如 boost,智能指针技术应运而生。

C++ 98/03 的尝试——std::auto_ptr

在 2019 年讨论 std::auto_ptr 不免有点让人怀疑是不是有点过时了,确实如此,随着 C++11 标准的出现(最新标准是 C++20),std::auto_ptr 已经被彻底废弃了,取而代之是 std::unique_ptr。然而,我之所以还向你介绍一下 std::auto_ptr 的用法以及它的设计不足之处是想让你了解 C++ 语言中智能指针的发展过程,一项技术如果我们了解它过去的样子和发展的轨迹,我们就能更好地掌握它,不是吗?

std::auto_ptr 的基本用法如下代码所示:

#include <memory>

int main()
{
    //初始化方式1
    std::auto_ptr<int> sp1(new int(8));
    //初始化方式2
    std::auto_ptr<int> sp2;
    sp2.reset(new int(8));

    return 0;
}

智能指针对象 sp1 和 sp2 均持有一个在堆上分配 int 对象,其值均是 8,这两块堆内存均可以在 sp1 和 sp2 释放时得到释放。这是 std::auto_ptr 的基本用法。

sp 是 smart pointer(智能指针)的简写。

std::auto_ptr 真正让人容易误用的地方是其不常用的复制语义,即当复制一个 std::auto_ptr 对象时(拷贝复制或 operator = 复制),原对象所持有的堆内存对象也会转移给复制出来的对象。示例代码如下:

#include <iostream>
#include <memory>

int main()
{
    //测试拷贝构造
    std::auto_ptr<int> sp1(new int(8));
    std::auto_ptr<int> sp2(sp1);
    if (sp1.get() != NULL)
    {
        std::cout << "sp1 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp1 is empty." << std::endl;
    }

    if (sp2.get() != NULL)
    {
        std::cout << "sp2 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp2 is empty." << std::endl;
    }

    //测试赋值构造
    std::auto_ptr<int> sp3(new int(8));
    std::auto_ptr<int> sp4;
    sp4 = sp3;
    if (sp3.get() != NULL)
    {
        std::cout << "sp3 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp3 is empty." << std::endl;
    }

    if (sp4.get() != NULL)
    {
        std::cout << "sp4 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp4 is empty." << std::endl;
    }

    return 0;
}

上述代码中分别利用拷贝构造(sp1 => sp2)和 赋值构造(sp3 => sp4)来创建新的 std::auto_ptr 对象,因此 sp1 持有的堆对象被转移给 sp2,sp3 持有的堆对象被转移给 sp4。我们得到程序执行结果如下:

[root@iZ238vnojlyZ testx]# g++ -g -o test_auto_ptr test_auto_ptr.cpp
[root@iZ238vnojlyZ testx]# ./test_auto_ptr 
sp1 is empty.
sp2 is not empty.
sp3 is empty.
sp4 is not empty.

由于 std::auto_ptr 这种不常用的复制语义,我们应该避免在 stl 容器中使用 std::auto_ptr,例如我们绝不应该写出如下代码:

std::vector<std::auto_ptr<int>> myvectors;

当用算法对容器操作的时候(如最常见的容器元素遍历),很难避免不对容器中的元素实现赋值传递,这样便会使容器中多个元素被置为空指针,这不是我们想看到的,会造成很多意想不到的错误。

以史为鉴,作为 std::auto_ptr 的替代者 std::unique_ptr 吸取了这个经验教训。下文会来详细介绍。

正因为 std::auto_ptr 的设计存在如此重大缺陷,C++11 标准在充分借鉴和吸收了 boost 库中智能指针的设计思想,引入了三种类型的智能指针,即 std::unique_ptr、std::shared_ptr 和 std::weak_ptr。

boost 还有 scoped_ptr,C++11 并没有全部照搬,而是选择了三个最实用的指针类型。在 C++11 中可以通过 std::unique_ptr 达到与 boost::scoped_ptr 一样的效果。

所有的智能指针类(包括 std::unique_ptr)均包含于头文件 中。

正因为存在上述设计上的缺陷,在 C++11及后续语言规范中 std::auto_ptr 已经被废弃,你的代码不应该再使用它。

std::unique_ptr

std::unique_ptr 对其持有的堆内存具有唯一拥有权,也就是说引用计数永远是 1,std::unique_ptr 对象销毁时会释放其持有的堆内存。可以使用以下方式初始化一个 std::unique_ptr 对象:

//初始化方式1
std::unique_ptr<int> sp1(new int(123));

//初始化方式2
std::unique_ptr<int> sp2;
sp2.reset(new int(123));

//初始化方式3
std::unique_ptr<int> sp3 = std::make_unique<int>(123);

你应该尽量使用初始化方式 3 的方式去创建一个 std::unique_ptr 而不是方式 1 和 2,因为形式 3 更安全,原因 Scott Meyers 在其《Effective Modern C++》中已经解释过了,有兴趣的读者可以阅读此书相关章节。

令很多人对 C++11 规范不满的地方是,C++11 新增了 std::make_shared() 方法创建一个 std::shared_ptr 对象,却没有提供相应的 std::make_unique() 方法创建一个 std::unique_ptr 对象,这个方法直到 C++14 才被添加进来。当然,在 C++11 中你很容易实现出这样一个方法来:
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&& ...params)
{
    return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

鉴于 std::auto_ptr 的前车之鉴,std::unique_ptr 禁止复制语义,为了达到这个效果,std::unique_ptr 类的拷贝构造函数和赋值运算符(operator =)被标记为 delete。

template <class T>
class unique_ptr
{
    //省略其他代码...

    //拷贝构造函数和赋值运算符被标记为delete
    unique_ptr(const unique_ptr&) = delete;
    unique_ptr& operator=(const unique_ptr&) = delete;
};

因此,下列代码是无法通过编译的:

std::unique_ptr<int> sp1(std::make_unique<int>(123));;

//以下代码无法通过编译
//std::unique_ptr<int> sp2(sp1);
std::unique_ptr<int> sp3;
//以下代码无法通过编译
//sp3 = sp1;

禁止复制语义也存在特例,即可以通过一个函数返回一个 std::unique_ptr:

#include <memory>

std::unique_ptr<int> func(int val)
{
    std::unique_ptr<int> up(new int(val));
    return up;
}

int main()
{
    std::unique_ptr<int> sp1 = func(123);

    return 0;
}

上述代码从 func 函数中得到一个 std::unique_ptr 对象,然后返回给 sp1。

既然 std::unique_ptr 不能复制,那么如何将一个 std::unique_ptr 对象持有的堆内存转移给另外一个呢?答案是使用移动构造,示例代码如下:

#include <memory>

int main()
{
    std::unique_ptr<int> sp1(std::make_unique<int>(123));

    std::unique_ptr<int> sp2(std::move(sp1));

    std::unique_ptr<int> sp3;
    sp3 = std::move(sp2);

    return 0;
}

以上代码利用 std::move 将 sp1 持有的堆内存(值为 123)转移给 sp2,再把 sp2 转移给 sp3。最后,sp1 和 sp2 不再持有堆内存的引用,变成一个空的智能指针对象。并不是所有的对象的 std::move 操作都有意义,只有实现了移动构造函数(Move Constructor)或移动赋值运算符(operator =)的类才行,而 std::unique_ptr 正好实现了这二者,以下是实现伪码:

template<typename T, typename Deletor>
class unique_ptr
{
    //其他函数省略...
public:
    unique_ptr(unique_ptr&& rhs)
    {
        this->m_pT = rhs.m_pT;
        //源对象释放
        rhs.m_pT = nullptr;
    }

    unique_ptr& operator=(unique_ptr&& rhs)
    {
        this->m_pT = rhs.m_pT;
        //源对象释放
        rhs.m_pT = nullptr;
        return *this;
    }

private:
    T*    m_pT;
};

这是 std::unique_ptr 具有移动语义的原因,希望读者可以理解之。关于移动构造和 std::move,我们将在后面章节详细介绍。

std::unique_ptr 不仅可以持有一个堆对象,也可以持有一组堆对象,示例如下:

#include <iostream>
#include <memory>

int main()
{
    //创建10个int类型的堆对象
    //形式1
    std::unique_ptr<int[]> sp1(new int[10]);

    //形式2
    std::unique_ptr<int[]> sp2;
    sp2.reset(new int[10]);
    //形式3
    std::unique_ptr<int[]> sp3(std::make_unique<int[]>(10));

    for (int i = 0; i < 10; ++i)
    {
        sp1[i] = i;
        sp2[i] = i;
        sp3[i] = i;
    }

    for (int i = 0; i < 10; ++i)
    {
        std::cout << sp1[i] << ", " << sp2[i] << ", " << sp3[i] << std::endl;
    }

    return 0;
}

程序执行结果如下:

[root@myaliyun testmybook]# g++ -g -o test_unique_ptr_with_array test_unique_ptr_with_array.cpp -std=c++17
[root@myaliyun testmybook]# ./test_unique_ptr_with_array 
0, 0, 0
1, 1, 1
2, 2, 2
3, 3, 3
4, 4, 4
5, 5, 5
6, 6, 6
7, 7, 7
8, 8, 8
9, 9, 9

std::shared_ptr 和 std::weak_ptr 也可以持有一组堆对象,用法与 std::unique_ptr 相同,下文不再赘述。

自定义智能指针对象持有的资源的释放函数

默认情况下,智能指针对象在析构时只会释放其持有的堆内存(调用 delete 或者 delete[]),但是假设这块堆内存代表的对象还对应一种需要回收的资源(如操作系统的套接字句柄、文件句柄等),我们可以通过自定义智能指针的资源释放函数。假设现在有一个 Socket 类,对应着操作系统的套接字句柄,在回收时需要关闭该对象,我们可以如下自定义智能指针对象的资源析构函数,这里以 std::unique_ptr 为例:

#include <iostream>
#include <memory>

class Socket
{
public:
    Socket()
    {

    }

    ~Socket()
    {

    }

    //关闭资源句柄
    void close()
    {

    }
};

int main()
{
    auto deletor = [](Socket* pSocket) {
        //关闭句柄
        pSocket->close();
        //TODO: 你甚至可以在这里打印一行日志...
        delete pSocket;
    };

    std::unique_ptr<Socket, void(*)(Socket * pSocket)> spSocket(new Socket(), deletor);

    return 0;
}

自定义 std::unique_ptr 的资源释放函数其规则是:

std::unique_ptr<T, DeletorFuncPtr>

其中 T 是你要释放的对象类型,DeletorPtr 是一个自定义函数指针。上述代码 33 行表示 DeletorPtr 有点复杂,我们可以使用 decltype(deletor) 让编译器自己推导 deletor 的类型,因此可以将 33 行代码修改为:

std::unique_ptr<Socket, decltype(deletor)> spSocket(new Socket(), deletor);

std::shared_ptr

std::unique_ptr 对其持有的资源具有独占性,而 std::shared_ptr 持有的资源可以在多个 std::shared_ptr 之间共享,每多一个 std::shared_ptr 对资源的引用,资源引用计数将增加 1,每一个指向该资源的 std::shared_ptr 对象析构时,资源引用计数减 1,最后一个 std::shared_ptr 对象析构时,发现资源计数为 0,将释放其持有的资源。多个线程之间,递增和减少资源的引用计数是安全的。(注意:这不意味着多个线程同时操作 std::shared_ptr 引用的对象是安全的)。std::shared_ptr 提供了一个 use_count() 方法来获取当前持有资源的引用计数。除了上面描述的,std::shared_ptr 用法和 std::unique_ptr 基本相同。

下面是一个初始化 std::shared_ptr 的示例:

//初始化方式1
std::shared_ptr<int> sp1(new int(123));

//初始化方式2
std::shared_ptr<int> sp2;
sp2.reset(new int(123));

//初始化方式3
std::shared_ptr<int> sp3;
sp3 = std::make_shared<int>(123);

和 std::unique_ptr 一样,你应该优先使用 std::make_shared 去初始化一个 std::shared_ptr 对象。

再来看另外一段代码:

#include <iostream>
#include <memory>

class A
{
public:
    A()
    {
        std::cout << "A constructor" << std::endl;
    }

    ~A()
    {
        std::cout << "A destructor" << std::endl;
    }
};

int main()
{
    {
        //初始化方式1
        std::shared_ptr<A> sp1(new A());

        std::cout << "use count: " << sp1.use_count() << std::endl;

        //初始化方式2
        std::shared_ptr<A> sp2(sp1);
        std::cout << "use count: " << sp1.use_count() << std::endl;

        sp2.reset();
        std::cout << "use count: " << sp1.use_count() << std::endl;

        {
            std::shared_ptr<A> sp3 = sp1;
            std::cout << "use count: " << sp1.use_count() << std::endl;
        }

        std::cout << "use count: " << sp1.use_count() << std::endl;
    }

    return 0;
}
  • 上述代码 22 行 sp1 构造时,同时触发对象 A 的构造,因此 A 的构造函数会执行;
  • 此时只有一个 sp1 对象引用 22 行 new 出来的 A 对象(为了叙述方便,下文统一称之为资源对象 A),因此代码 24 行打印出来的引用计数值为 1;
  • 代码 27 行,利用 sp1 拷贝一份 sp2,导致代码 28 行打印出来的引用计数为 2;
  • 代码 30 行调用 sp2 的 reset() 方法,sp2 释放对资源对象 A 的引用,因此代码 31 行打印的引用计数值再次变为 1;
  • 代码 34 行 利用 sp1 再次 创建 sp3,因此代码 35 行打印的引用计数变为 2;
  • 程序执行到 36 行以后,sp3 出了其作用域被析构,资源 A 的引用计数递减 1,因此 代码 38 行打印的引用计数为 1;
  • 程序执行到 39 行以后,sp1 出了其作用域被析构,在其析构时递减资源 A 的引用计数至 0,并析构资源 A 对象,因此类 A 的析构函数被调用。

所以整个程序的执行结果如下:

[root@myaliyun testmybook]# ./test_shared_ptr_use_count 
A constructor
use count: 1
use count: 2
use count: 1
use count: 2
use count: 1
A destructor

std::enable_shared_from_this

实际开发中,有时候需要在类中返回包裹当前对象(this)的一个 std::shared_ptr 对象给外部使用,C++ 新标准也为我们考虑到了这一点,有如此需求的类只要继承自 std::enable_shared_from_this 模板对象即可。用法如下:

#include <iostream>
#include <memory>

class A : public std::enable_shared_from_this<A>
{
public:
    A()
    {
        std::cout << "A constructor" << std::endl;
    }

    ~A()
    {
        std::cout << "A destructor" << std::endl;
    }

    std::shared_ptr<A> getSelf()
    {
        return shared_from_this();
    }
};

int main()
{
    std::shared_ptr<A> sp1(new A());

    std::shared_ptr<A> sp2 = sp1->getSelf();

    std::cout << "use count: " << sp1.use_count() << std::endl;

    return 0;
}

上述代码中,类 A 的继承 std::enable_shared_from_this 并提供一个 getSelf() 方法返回自身的 std::shared_ptr 对象,在 getSelf() 中调用 shared_from_this() 即可。

std::enable_shared_from_this 用起来比较方便,但是也存在很多不易察觉的陷阱。

陷阱一:不应该共享栈对象的 this 给智能指针对象

假设我们将上面代码 main 函数 25 行生成 A 对象的方式改成一个栈变量,即:

//其他相同代码省略...

int main()
{
    A a;

    std::shared_ptr<A> sp2 = a.getSelf();

    std::cout << "use count: " << sp2.use_count() << std::endl;

    return 0;
}

运行修改后的代码会发现程序在 std::shared_ptr sp2 = a.getSelf(); 产生崩溃。这是因为,智能指针管理的是堆对象,栈对象会在函数调用结束后自行销毁,因此不能通过 shared_from_this() 将该对象交由智能指针对象管理。切记:智能指针最初设计的目的就是为了管理堆对象的(即那些不会自动释放的资源)。

陷阱二:避免 std::enable_shared_from_this 的循环引用问题

再来看另外一段代码:

// test_std_enable_shared_from_this.cpp : This file contains the 'main' function. Program execution begins and ends there.
//
#include <iostream>
#include <memory>

class A : public std::enable_shared_from_this<A>
{
public:
    A()
    {
        m_i = 9;
        //注意:
        //比较好的做法是在构造函数里面调用shared_from_this()给m_SelfPtr赋值
        //但是很遗憾不能这么做,如果写在构造函数里面程序会直接崩溃

        std::cout << "A constructor" << std::endl;
    }

    ~A()
    {
        m_i = 0;

        std::cout << "A destructor" << std::endl;
    }

    void func()
    {
        m_SelfPtr = shared_from_this();
    }

public:
    int                 m_i;
    std::shared_ptr<A>  m_SelfPtr;

};

int main()
{
    {
        std::shared_ptr<A> spa(new A());
        spa->func();
    }

    return 0;
}

乍一看上面的代码好像看不出什么问题,让我们来实际运行一下看看输出结果:

[root@myaliyun testmybook]# g++ -g -o test_std_enable_shared_from_this_problem test_std_enable_shared_from_this_problem.cpp
[root@myaliyun testmybook]# ./test_std_enable_shared_from_this_problem
A constructor

我们发现在程序的整个生命周期内,只有 A 类构造函数的调用输出,没有 A 类析构函数的调用输出,这意味着 new 出来的 A 对象产生了内存泄漏了!

我们来分析一下为什么 new 出来的 A 对象得不到释放。当程序执行到 42 行后,spa 出了其作用域准备析构,在析构时其发现仍然有另外的一个 std::shared_ptr 对象即 A::m_SelfPtr 引用了 A,因此 spa 只会将 A 的引用计数递减为 1,然后就销毁自身了。现在留下一个矛盾的处境:必须销毁 A 才能销毁其成员变量 m_SelfPtr,而销毁 m_SelfPtr 必须先销毁 A。这就是所谓的 std::enable_shared_from_this 的循环引用问题。我们在实际开发中应该避免做出这样的逻辑设计,这种情形下即使使用了智能指针也会造成内存泄漏。也就是说一个资源的生命周期可以交给一个智能指针对象,但是该智能指针的生命周期不可以再交给整个资源来管理。

std::weak_ptr

std::weak_ptr 是一个不控制资源生命周期的智能指针,是对对象的一种弱引用,只是提供了对其管理的资源的一个访问手段,引入它的目的为协助 std::shared_ptr 工作。

std::weak_ptr 可以从一个 std::shared_ptr 或另一个 std::weak_ptr 对象构造,std::shared_ptr 可以直接赋值给 std::weak_ptr ,也可以通过 std::weak_ptr 的 lock() 函数来获得 std::shared_ptr。它的构造和析构不会引起引用计数的增加或减少。std::weak_ptr 可用来解决 std::shared_ptr 相互引用时的死锁问题(即两个std::shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为 0, 资源永远不会释放)。

示例代码如下:

#include <iostream>
#include <memory>

int main()
{
    //创建一个std::shared_ptr对象
    std::shared_ptr<int> sp1(new int(123));
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过构造函数得到一个std::weak_ptr对象
    std::weak_ptr<int> sp2(sp1);
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过赋值运算符得到一个std::weak_ptr对象
    std::weak_ptr<int> sp3 = sp1;
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过一个std::weak_ptr对象得到另外一个std::weak_ptr对象
    std::weak_ptr<int> sp4 = sp2;
    std::cout << "use count: " << sp1.use_count() << std::endl;

    return 0;
}

程序执行结果如下:

[root@myaliyun testmybook]# g++ -g -o test_weak_ptr test_weak_ptr.cpp 
[root@myaliyun testmybook]# ./test_weak_ptr
use count: 1
use count: 1
use count: 1
use count: 1

无论通过何种方式创建 std::weak_ptr 都不会增加资源的引用计数,因此每次输出引用计数的值都是 1。

既然,std::weak_ptr 不管理对象的生命周期,那么其引用的对象可能在某个时刻被销毁了,如何得知呢?std::weak_ptr 提供了一个 expired() 方法来做这一项检测,返回 true,说明其引用的资源已经不存在了;返回 false,说明该资源仍然存在,这个时候可以使用 std::weak_ptr 的 lock() 方法得到一个 std::shared_ptr 对象然后继续操作资源,以下代码演示了该用法:

//tmpConn_ 是一个 std::weak_ptr<TcpConnection> 对象
//tmpConn_引用的TcpConnection已经销毁,直接返回
if (tmpConn_.expired())
    return;

std::shared_ptr<TcpConnection> conn = tmpConn_.lock();
if (conn)
{
    //对conn进行操作,省略...
}

有读者可能对上述代码产生疑问,既然使用了 std::weak_ptr 的 expired() 方法判断了对象是否存在,为什么不直接使用 std::weak_ptr 对象对引用资源进行操作呢?实际上这是行不通的,std::weak_ptr 类没有重写 operator-> 和 operator* 方法,因此不能像 std::shared_ptr 或 std::unique_ptr 一样直接操作对象,同时 std::weak_ptr 类也没有重写 operator! 操作,因此也不能通过 std::weak_ptr 对象直接判断其引用的资源是否存在:

#include <memory>

class A
{
public:
    void doSomething()
    {

    }
};

int main()
{    
    std::shared_ptr<A> sp1(new A());

    std::weak_ptr<A> sp2(sp1);

    //正确代码
    if (sp1)
    {
        //正确代码
        sp1->doSomething();
        (*sp1).doSomething();
    }

    //正确代码
    if (!sp1)
    {

    }

    //错误代码,无法编译通过
    //if (sp2)
    //{
    //    //错误代码,无法编译通过
    //    sp2->doSomething();
    //    (*sp2).doSomething();
    //}

    //错误代码,无法编译通过
    //if (!sp2)
    //{

    //}

    return 0;
}

之所以 std::weak_ptr 不增加引用资源的引用计数不管理资源的生命周期,是因为,即使它实现了以上说的几个方法,调用它们也是不安全的,因为在调用期间,引用的资源可能恰好被销毁了,这会造成棘手的错误和麻烦。

因此,std::weak_ptr 的正确使用场景是那些资源如果可能就使用,如果不可使用则不用的场景,它不参与资源的生命周期管理。例如,网络分层结构中,Session 对象(会话对象)利用 Connection 对象(连接对象)提供的服务工作,但是 Session 对象不管理 Connection 对象的生命周期,Session 管理 Connection 的生命周期是不合理的,因为网络底层出错会导致 Connection 对象被销毁,此时 Session 对象如果强行持有 Connection 对象与事实矛盾。

std::weak_ptr 的应用场景,经典的例子是订阅者模式或者观察者模式中。这里以订阅者为例来说明,消息发布器只有在某个订阅者存在的情况下才会向其发布消息,而不能管理订阅者的生命周期。

class Subscriber
{

};

class SubscribeManager
{
public:
    void publish()
    {
        for (const auto& iter : m_subscribers)
        {
            if (!iter.expired())
            {
                //TODO:给订阅者发送消息
            }
        }
    }

private:
    std::vector<std::weak_ptr<Subscriber>>   m_subscribers;
};

智能指针对象的大小

一个 std::unique_ptr 对象大小与裸指针大小相同(即 sizeof(std::unique_ptr) == sizeof(void*)),而 std::shared_ptr 的大小是 std::unique_ptr 的一倍。以下是我分别在 Visual Studio 2019 和 gcc/g++ 4.8 上(二者都编译成 x64 程序)的测试结果:

测试代码

#include <iostream>
#include <memory>
#include <string>

int main()
{
    std::shared_ptr<int> sp0;
    std::shared_ptr<std::string> sp1;
    sp1.reset(new std::string());
    std::unique_ptr<int> sp2;
    std::weak_ptr<int> sp3;

    std::cout << "sp0 size: " << sizeof(sp0) << std::endl;
    std::cout << "sp1 size: " << sizeof(sp1) << std::endl;
    std::cout << "sp2 size: " << sizeof(sp2) << std::endl;
    std::cout << "sp3 size: " << sizeof(sp3) << std::endl;

    return 0;
}

Visual Studio 2019 运行结果:

gcc/g++ 运行结果:

在 32 位机器上,std_unique_ptr 占 4 字节,std::shared_ptr 和 std::weak_ptr 占 8 字节;在 64 位机器上,std_unique_ptr 占 8 字节,std::shared_ptr 和 std::weak_ptr 占 16 字节。也就是说,std_unique_ptr 的大小总是和原始指针大小一样,std::shared_ptr 和 std::weak_ptr 大小是原始指针的一倍。

智能指针使用注意事项

C++ 新标准提倡的理念之一是不应该再手动调用 delete 或者 free 函数去释放内存了,而应该把它们交给新标准提供的各种智能指针对象。C++ 新标准中的各种智能指针是如此的实用与强大,在现代 C++ 项目开发中,读者应该尽量去使用它们。智能指针虽然好用,但稍不注意,也可能存在许多难以发现的 bug,这里我根据经验总结了几条:

  • 一旦一个对象使用智能指针管理后,就不该再使用原始裸指针去操作;

看一段代码:

  #include <memory>
  
  class Subscriber
  {
  
  };
  
  int main()
  {    
    Subscriber* pSubscriber = new Subscriber();
  
    std::unique_ptr<Subscriber> spSubscriber(pSubscriber);
  
    delete pSubscriber;
  
    return 0;
  }

这段代码利用创建了一个堆对象 Subscriber,然后利用智能指针 spSubscriber 去管理之,可以却私下利用原始指针销毁了该对象,这让智能指针对象 spSubscriber 情何以堪啊?

记住,一旦智能指针对象接管了你的资源,所有对资源的操作都应该通过智能指针对象进行,不建议再通过原始指针进行操作了。当然,除了 std::weak_ptr,std::unique_ptr 和 std::shared_ptr 都提供了获取原始指针的方法——get() 函数。

  int main()
  {    
    Subscriber* pSubscriber = new Subscriber();
  
    std::unique_ptr<Subscriber> spSubscriber(pSubscriber);
  
    //pTheSameSubscriber和pSubscriber指向同一个对象
    Subscriber* pTheSameSubscriber= spSubscriber.get();
  
    return 0;
  }
  • 分清楚场合应该使用哪种类型的智能指针;

通常情况下,如果你的资源不需要在其他地方共享,那么应该优先使用 std::unique_ptr,反之使用 std::shared_ptr,当然这是在该智能指针需要管理资源的生命周期的情况下;如果不需要管理对象的生命周期,请使用 std::weak_ptr。

  • 认真考虑,避免操作某个引用资源已经释放的智能指针;

前面的例子,一定让你觉得非常容易知道一个智能指针的持有的资源是否还有效,但是还是建议在不同场景谨慎一点,有些场景是很容易造成误判。例如下面的代码:

  #include <iostream>
  #include <memory>
  
  class T
  {
  public:
    void doSomething()
    {
        std::cout << "T do something..." << m_i << std::endl;
    }
  
  private:
    int     m_i;
  };
  
  int main()
  {    
    std::shared_ptr<T> sp1(new T());
    const auto& sp2 = sp1;
  
    sp1.reset();
  
    //由于sp2已经不再持有对象的引用,程序会在这里出现意外的行为
    sp2->doSomething();
  
    return 0;
  }

上述代码中,sp2 是 sp1 的引用,sp1 被置空后,sp2 也一同为空。这时候调用 sp2->doSomething(),sp2->(即 operator->)在内部会调用 get() 方法获取原始指针对象,这时会得到一个空指针(地址为 0),继续调用 doSomething() 导致程序崩溃。

你一定仍然觉得这个例子也能很明显地看出问题,ok,让我们把这个例子放到实际开发中再来看一下:

  //连接断开
  void MonitorServer::OnClose(const std::shared_ptr<TcpConnection>& conn)
  {    
    std::lock_guard<std::mutex> guard(m_sessionMutex);
    for (auto iter = m_sessions.begin(); iter != m_sessions.end(); ++iter)
    {
        //通过比对connection对象找到对应的session
        if ((*iter)->GetConnectionPtr() == conn)
        {
            m_sessions.erase(iter);
            //注意这里:程序在此处崩溃
            LOGI("monitor client disconnected: %s", conn->peerAddress().toIpPort().c_str());
            break;
        }
    }
  }

这段代码不是我杜撰的,而是来自于我实际的一个商业项目中。注意代码中我提醒注意的地方,该段程序会在代码 12 行处崩溃,崩溃原因是调用了 conn->peerAddress() 方法。为什么这个方法的调用可能会引起崩溃?现在可以一目了然地看出了吗?

崩溃原因是传入的 conn 对象和上一个例子中的 sp2 一样都是另外一个 std::shared_ptr 的引用,当连接断开时,对应的 TcpConnection 对象可能早已被销毁,而 conn 引用就会变成空指针(严格来说是不再拥有一个 TcpConnection 对象),此时调用 TcpConnection 的 peerAddress() 方法就会产生和上一个示例一样的错误。

  • 作为类成员变量时,应该优先使用前置声明(forward declarations)

我们知道,为了减小编译依赖加快编译速度和生成二进制文件的大小,C/C++ 项目中一般在 *.h 文件对于指针类型尽量使用前置声明,而不是直接包含对应类的头文件。例如:

  //Test.h
  //在这里使用A的前置声明,而不是直接包含A.h文件
  class A;
  
  class Test
  {
  public:
    Test();
    ~Test();
  
  private:
    A*      m_pA;
  };

同样的道理,在头文件中当使用智能指针对象作为类成员变量时,也应该优先使用前置声明去引用智能指针对象的包裹类,而不是直接包含包裹类的头文件。

  //Test.h
  #include <memory>
  
  //智能指针包裹类A,这里优先使用A的前置声明,而不是直接包含A.h
  class A;
  
  class Test
  {
  public:
    Test();
    ~Test();
  
  private:  
    std::unique_ptr<A>  m_spA;
  };

C++ 新标准中的智能指针我想介绍的就这么多了,Modern C/C++ 已经变为 C/C++ 开发的趋势,希望读者能善用和熟练使用本节介绍的后三种智能指针对象。


C++17结构化绑定

C++ 17 结构化绑定

stl 的 map 容器很多读者应该都很熟悉,map 容器提供了一个 insert 方法,我们用该方法向 map 中插入元素,但是应该很少有人记得 insert 方法的返回值是什么类型,让我们来看一下 C++98/03 提供的 insert 方法的签名:

std::pair<iterator,bool> insert( const value_type& value );

这里我们仅关心其返回值,这个返回值是一个 std::pair 类型,由于 map 中的元素的 key 不允许重复,所以如果 insert 方法调用成功,T1 是被成功插入到 map 中的元素的迭代器,T2 的类型为 bool,此时其值为 true(表示插入成功);如果 insert 由于 key 重复,T1 是造成 insert 插入失败、已经存在于 map 中的元素的迭代器,此时 T2 的值为 false(表示插入失败)。

在 C++98/03 标准中我们可以使用 std::pair 的 first 和 second 属性来分别引用 T1 和 T2 的值。如下面的我们熟悉的代码所示:

#include <iostream>
#include <string>
#include <map>

int main()
{
    std::map<std::string, int> cities;
    cities["beijing"]   = 0;
    cities["shanghai"]  = 1;
    cities["shenzhen"]  = 2;
    cities["guangzhou"] = 3;

    //for (const auto& [key, value] : m)
    //{
    //    std::cout << key << ": " << value << std::endl;
    //}

    //这一行在 C++11 之前写法实在太麻烦了,
    //std::pair<std::map<std::string, int>::iterator, int> insertResult = cities.insert(std::pair<std::string, int>("shanghai", 2));
    //C++ 11中我们写成:
    auto insertResult = cities.insert(std::pair<std::string, int>("shanghai", 2));

    std::cout << "Is insertion successful ? " << (insertResult.second ? "true" : "false") 
              << ", element key: " << insertResult.first->first << ", value: " << insertResult.first->second << std::endl;

    return 0;
}

代码 19 行实在太啰嗦了,我们使用 auto 关键字让编译器自动推导类型。

std::pair 一般只能表示两个元素,C++11 标准中引入了 std::tuple 类型,有了这个类型,我们就可以放任意个元素了,原来需要定义成结构体的 POD 对象我们可以直接使用 std::tuple 表示,例如下面表示用户信息的结构体:

struct UserInfo
{
    std::string username;
    std::string password;
    int         gender;
    int         age;
    std::string address;
};

int main()
{
    UserInfo userInfo = { "Tom", "123456", 0, 25, "Pudong Street" };
    std::string username = userInfo.username;
    std::string password = userInfo.password;
    int gender = userInfo.gender;
    int age = userInfo.age;
    std::string address = userInfo.address;

    return 0;
}

我们不再需要定义 struct UserInfo 这样的对象,可以直接使用 std::tuple 表示:

int main()
{    
    std::tuple<std::string, std::string, int, int, std::string> userInfo("Tom", "123456", 0, 25, "Pudong Street");

    std::string username = std::get<0>(userInfo);
    std::string password = std::get<1>(userInfo);
    int gender = std::get<2>(userInfo);
    int age = std::get<3>(userInfo);
    std::string address = std::get<4>(userInfo);

    return 0;
}

从 std::tuple 中获取对应位置的元素,我们使用 std::get ,其中 N 是元素的序号(从 0 开始)。

与定义结构体相比,通过 std::pair 的 first 和 second 还是 std::tuple 的 std::get 方法来获取元素子属性,这些代码都是非常难以维护的,其根本原因是 first 和 second 这样的命名不能做到见名知意。

C++17 引入的结构化绑定(Structured Binding )将我们从这类代码中解放出来。结构化绑定使用语法如下:

auto [a, b, c, ...] = expression;
auto [a, b, c, ...] { expression };
auto [a, b, c, ...] ( expression );

右边的 expression 可以是一个函数调用、花括号表达式或者支持结构化绑定的某个类型的变量。例如:

//形式1
auto [iterator, inserted] = someMap.insert(...);
//形式2
double myArray[3] = { 1.0, 2.0, 3.0 };
auto [a, b, c] = myArray;
//形式3
struct Point
{
    double x;
    double y;
};
Point myPoint(10.0, 20.0);
auto [myX, myY] = myPoint;

这样,我们可以给用于绑定到目标的变量名(语法中的 a、b、c)起一个有意义的名字。

需要注意的是,绑定名称 a、b、c 是绑定目标的一份拷贝,当绑定类型不是基础数据类型时,如果你的本意不是想要得到绑定目标的副本,为了避免拷贝带来的不必要开销,建议使用引用,如果不需要修改绑定目标建议使用 const 引用。示例如下:

double myArray[3] = { 1.0, 2.0, 3.0 };
auto& [a, b, c] = myArray;
//形式3
struct Point
{
    double x;
    double y;
};
Point myPoint(10.0, 20.0);
const auto& [myX, myY] = myPoint;

结构化绑定(Structured Binding )是 C++17 引入的一个非常好用的语法特性。有了这种语法,在遍历像 map 这样的容器时,我们可以使用更简洁和清晰的代码去遍历这些容器了:

std::map<std::string, int> cities;
cities["beijing"] = 0;
cities["shanghai"] = 1;
cities["shenzhen"] = 2;
cities["guangzhou"] = 3;

for (const auto& [cityName, cityNumber] : cities)
{
    std::cout << cityName << ": " << cityNumber << std::endl;
}

上述代码中 cityName 和 cityNumber 可以更好地反映出这个 map 容器的元素内容。

我们再来看一个例子,某 WebSocket 网络库(https://github.com/uNetworking/uWebSockets)中有如下代码:

std::pair<int, bool> uncork(const char *src = nullptr, int length = 0, bool optionally = false) {
        LoopData *loopData = getLoopData();

        if (loopData->corkedSocket == this) {
            loopData->corkedSocket = nullptr;

            if (loopData->corkOffset) {
                /* Corked data is already accounted for via its write call */
                auto [written, failed] = write(loopData->corkBuffer, loopData->corkOffset, false, length);
                loopData->corkOffset = 0;

                if (failed) {
                    /* We do not need to care for buffering here, write does that */
                    return {0, true};
                }
            }

            /* We should only return with new writes, not things written to cork already */
            return write(src, length, optionally, 0);
        } else {
            /* We are not even corked! */
            return {0, false};
        }
    }

代码的第 9 行 write 函数返回类型是 std::pair,被绑定到 [written, failed] 这两个变量中去。前者在写入成功的情况下表示实际写入的字节数,后者表示是否写入成功。

std::pair<int, bool> write(const char *src, int length, bool optionally = false, int nextLength = 0) {
    //具体实现省略...
}

结构化绑定的限制

结构化绑定不能使用 constexpr 修饰或被申明为 static,例如:

//正常编译
auto [first, second] = std::pair<int, int>(1, 2);
//无法编译通过
//constexpr auto [first, second] = std::pair<int, int>(1, 2);
//无法编译通过
//static auto [first, second] = std::pair<int, int>(1, 2);

注意:有些编译器也不支持在 lamda 表达式捕获列表中使用结构化绑定语法。


C++必须掌握的pimpl惯用法

pimpl 惯用法

现在这里有一个名为 CSocketClient 的网络通信类,定义如下:

/**
 * 网络通信的基础类, SocketClient.h
 * zhangyl 2017.07.11
 */
class CSocketClient
{
public:
    CSocketClient();
    ~CSocketClient();
 
public:  
    void SetProxyWnd(HWND hProxyWnd);

    bool    Init(CNetProxy* pNetProxy);
    bool    Uninit();
    
    int Register(const char* pszUser, const char* pszPassword); 
    void GuestLogin();  
    
    BOOL    IsClosed();
    BOOL	Connect(int timeout = 3);
    void    AddData(int cmd, const std::string& strBuffer);
    void    AddData(int cmd, const char* pszBuff, int nBuffLen);
    void    Close();

    BOOL    ConnectServer(int timeout = 3);
    BOOL    SendLoginMsg();
    BOOL    RecvLoginMsg(int& nRet);
    BOOL    Login(int& nRet);

private:
    void LoadConfig();
    static UINT CALLBACK SendDataThreadProc(LPVOID lpParam);
    static UINT CALLBACK RecvDataThreadProc(LPVOID lpParam);
    bool Send();
    bool Recv();
    bool CheckReceivedData();
    void SendHeartbeatPackage();

private:
    SOCKET                          m_hSocket;
    short                           m_nPort;
    char                            m_szServer[64];
    long                            m_nLastDataTime;        //最近一次收发数据的时间
    long                            m_nHeartbeatInterval;   //心跳包时间间隔,单位秒
    CRITICAL_SECTION                m_csLastDataTime;       //保护m_nLastDataTime的互斥体 
    HANDLE                          m_hSendDataThread;      //发送数据线程
    HANDLE                          m_hRecvDataThread;      //接收数据线程
    std::string                     m_strSendBuf;
    std::string                     m_strRecvBuf;
    HANDLE                          m_hExitEvent;
    bool                            m_bConnected;
    CRITICAL_SECTION                m_csSendBuf;
    HANDLE                          m_hSemaphoreSendBuf;
    HWND                            m_hProxyWnd;
    CNetProxy*                      m_pNetProxy;
    int                             m_nReconnectTimeInterval;    //重连时间间隔
    time_t                          m_nLastReconnectTime;        //上次重连时刻
    CFlowStatistics*                m_pFlowStatistics;
};

这段代码来源于笔者实际项目中开发的一个股票客户端的软件。

CSocketClient 类的 public 方法提供对外接口供第三方使用,每个函数的具体实现在 SocketClient.cpp 中,对第三方使用者不可见。在 Windows 系统上作为提供给第三方使用的库,一般需要提供给使用者 .h、.lib 和 .dll 文件,在 Linux 系统上需要提供 .h、***.a 或 .so* 文件。

不管是在哪个操作系统平台上,像 SocketClient.h 这样的头文件提供给第三方使用时,都会让库的作者心里隐隐不安——因为 SocketClient.h 文件中 SocketClient 类大量的成员变量和私有函数暴露了这个类太多的实现细节,很容易让使用者看出实现原理。这样的头文件,对于一些不想对使用者暴露核心技术实现的库和 sdk,是非常不好的。

那有没有什么办法既能保持对外的接口不变,又能尽量不暴露一些关键性的成员变量和私有函数的实现方法呢?有的。我们可以将代码稍微修改一下:

/**
 * 网络通信的基础类, SocketClient.h
 * zhangyl 2017.07.11
 */
class Impl;

class CSocketClient
{
public:
    CSocketClient();
    ~CSocketClient();
 
public:
    void SetProxyWnd(HWND hProxyWnd);

    bool    Init(CNetProxy* pNetProxy);
    bool    Uninit();

    int Register(const char* pszUser, const char* pszPassword);    
    void GuestLogin();  
    
    BOOL    IsClosed();
    BOOL	Connect(int timeout = 3);
    void    AddData(int cmd, const std::string& strBuffer);
    void    AddData(int cmd, const char* pszBuff, int nBuffLen);
    void    Close();

    BOOL    ConnectServer(int timeout = 3);
    BOOL    SendLoginMsg();
    BOOL    RecvLoginMsg(int& nRet);
    BOOL    Login(int& nRet);

private:
    Impl*	m_pImpl;
};

上述代码中,所有的关键性成员变量已经没有了,取而代之的是一个类型为 Impl 的指针成员变量 m_pImpl。

具体采用什么名称,读者完全可以根据自己的实际情况来定,不一定非要使用这里的 Impl 和 m_pImpl。

Impl 类型现在是完全对使用者透明,为了在当前类中可以使用 Impl,使用了一个前置声明:

//原代码第5行
class Impl;

然后我们就可以将刚才隐藏的成员变量放到这个类中去:

class Impl
{
public:
	Impl()
	{
        //TODO: 你可以在这里对成员变量做一些初始化工作
	}
	
	~Impl()
	{
        //TODO: 你可以在这里做一些清理工作
	}
	
public:
	SOCKET                          m_hSocket;
    short                           m_nPort;
    char                            m_szServer[64];
    long                            m_nLastDataTime;        //最近一次收发数据的时间
    long                            m_nHeartbeatInterval;   //心跳包时间间隔,单位秒
    CRITICAL_SECTION                m_csLastDataTime;       //保护m_nLastDataTime的互斥体 
    HANDLE                          m_hSendDataThread;      //发送数据线程
    HANDLE                          m_hRecvDataThread;      //接收数据线程
    std::string                     m_strSendBuf;
    std::string                     m_strRecvBuf;
    HANDLE                          m_hExitEvent;
    bool                            m_bConnected;
    CRITICAL_SECTION                m_csSendBuf;
    HANDLE                          m_hSemaphoreSendBuf;
    HWND                            m_hProxyWnd;
    CNetProxy*                      m_pNetProxy;
    int                             m_nReconnectTimeInterval;    //重连时间间隔
    time_t                          m_nLastReconnectTime;        //上次重连时刻
    CFlowStatistics*                m_pFlowStatistics;
};

接着我们在 CSocketClient 的构造函数中创建这个 m_pImpl 对象,在 CSocketClient 析构函数中释放这个对象。

CSocketClient::CSocketClient()
{
	m_pImpl = new Impl();
}

CSocketClient::~CSocketClient()
{
	delete m_pImpl;
}

这样,原来需要引用的成员变量,可以在 CSocketClient 内部使用 m_pImpl->变量名 来引用了。

这里仅仅以演示隐藏 CSocketClient 的成员变量为例,隐藏其私有方法与此类似,都是变成类 Impl 的方法。

需要强调的是,在实际开发中,由于 Impl 类是 CSocketClient 的辅助类, Impl 类没有独立存在的必要,所以一般会将 Impl 类定义成 CSocketClient 的内部类。即采用如下形式:

/**
 * 网络通信的基础类, SocketClient.h
 * zhangyl 2017.07.11
 */
class CSocketClient
{
public:
    CSocketClient();
    ~CSocketClient();

 //重复的代码省略...

private:
	class   Impl;
    Impl*	m_pImpl;
};

然后在 ClientSocket.cpp 中定义 Impl 类的实现:

/**
 * 网络通信的基础类, SocketClient.cpp
 * zhangyl 2017.07.11
 */
class  CSocketClient::Impl
{
public:
    void LoadConfig()
    {
    	//方法的具体实现
    }
    
    //其他方法省略...
    
public:
	SOCKET                          m_hSocket;
    short                           m_nPort;
    char                            m_szServer[64];
    long                            m_nLastDataTime;        //最近一次收发数据的时间
    long                            m_nHeartbeatInterval;   //心跳包时间间隔,单位秒
    CRITICAL_SECTION                m_csLastDataTime;       //保护m_nLastDataTime的互斥体 
    HANDLE                          m_hSendDataThread;      //发送数据线程
    HANDLE                          m_hRecvDataThread;      //接收数据线程
    std::string                     m_strSendBuf;
    std::string                     m_strRecvBuf;
    HANDLE                          m_hExitEvent;
    bool                            m_bConnected;
    CRITICAL_SECTION                m_csSendBuf;
    HANDLE                          m_hSemaphoreSendBuf;
    HWND                            m_hProxyWnd;
    CNetProxy*                      m_pNetProxy;
    int                             m_nReconnectTimeInterval;    //重连时间间隔
    time_t                          m_nLastReconnectTime;        //上次重连时刻
    CFlowStatistics*                m_pFlowStatistics;
}
 
CSocketClient::CSocketClient()
{
	m_pImpl = new Impl();
}

CSocketClient::~CSocketClient()
{
	delete m_pImpl;
}

现在CSocketClient 这个类除了保留对外的接口以外,其内部实现用到的变量和方法基本上对使用者不可见了。C++ 中对类的这种封装方式,我们称之为 pimpl 惯用法,即 Pointer to Implementation (也有人认为是 Private Implementation)。

在实际的开发中,Impl 类的声明和定义既可以使用 class 关键字也可以使用 struct 关键字。在 C++ 语言中,struct 类型可以定义成员方法,但 struct 所有成员变量和方法默认都是 public 的。

现在来总结一下这个方法的优点:

  • 核心数据成员被隐藏;
核心数据成员被隐藏,不必暴露在头文件中,对使用者透明,提高了安全性。
  • 降低编译依赖,提高编译速度;
由于原来的头文件的一些私有成员变量可能是非指针非引用类型的自定义类型,需要在当前类的头文件中包含这些类型的头文件,使用了 **pimpl** 惯用法以后,这些私有成员变量被移动到当前类的 cpp 文件中,因此头文件不再需要包含这些成员变量的类型头文件,当前头文件变“干净”,这样其他文件在引用这个头文件时,依赖的类型变少,加快了编译速度。
  • 接口与实现分离。
使用了 **pimpl** 惯用法之后,即使 **CSocketClient** 或者 **Impl** 类的实现细节发生了变化,对使用者都是透明的,对外的 **CSocketClient** 类声明仍然可以保持不变。例如我们可以增删改 Impl 的成员变量和成员方法而保持 **SocketClient.h** 文件内容不变;如果不使用 **pimpl** 惯用法,我们做不到不改变 **SocketClient.h** 文件而增删改 **CSocketClient** 类的成员。

智能指针用于 pimpl 惯用法

C++ 11 标准引入了智能指针对象,我们可以使用 std::unique_ptr 对象来管理上述用于隐藏具体实现的 m_pImpl 指针。

SocketClient.h 文件可以修改成如下方式:

#include <memory> //for std::unique_ptr  

class CSocketClient
{
public:
    CSocketClient();
    ~CSocketClient();

    //重复的代码省略...

private:
    struct                  Impl;
    std::unique_ptr<Impl>   m_pImpl;
};

SocketClient.cpp 中修改 CSocketClient 对象的构造函数和析构函数的实现如下:

构造函数

如果你的编译器仅支持 C++ 11 标准,我们可以按如下修改:

CSocketClient::CSocketClient()
{
    //C++11 标准并未提供 std::make_unique(),该方法是 C++14 提供的
    m_pImpl.reset(new Impl());
}

如果你的编译器支持 C++14 及以上标准,可以这么修改:

CSocketClient::CSocketClient() : m_pImpl(std::make_unique<Impl>())
{    
}

由于已经使用了智能指针来管理 m_pImpl 指向的堆内存,因此析构函数中不再需要显式释放堆内存:

CSocketClient::~CSocketClient()
{
    //不再需要显式 delete 了 
    //delete m_pImpl;
}

pimp 惯用法是 C/C++ 项目开发中一种非常实用的代码编写策略,建议读者掌握它。


用VisualStudio调试Linux程序

用Visual Studio调试Linux程序

用Visual Studio调试Linux程序?你真的没看错,这个是真的,不是标题党。当然如果你说VS2015及以上版本自带的Linux调试插件,那就算了。这些自带的插件调试一个有简单的main函数程序还凑合,稍微复杂点的程序,根本无法编译调试。

而本文介绍的主角是VS的另外一款插件Visual GDB,让我们欢迎主角登场,下面是正文。

使用Visual Studio+VisualGDB调试远程Linux程序

需要工具:

  1. Visual Studio 2013或以上版本(以下简称VS)
  2. VisualGDB(一款VS插件,官网为:http://visualgdb.com/)
  3. 含有调试符号的Linux程序文件(该程序文件为调试目标)
  4. Visual Assistant(番茄助手,另外一款VS插件)

在VS上安装完VisualGDB插件以后,有如下几种方式来对远程Linux机器上的程序进行调试:

  • 方法一、如果该程序已经启动,则可以使用VS菜单【Debug】->【Attach to Process...】。

img

这种方法有个缺点是,不能从开始启动的main函数处添加断点,自始至终地调试程序,查看完整程序运行脉络,所以下面推荐方法二。

  • 方法二、利用VS启动远程Linux机器上一个Linux程序文件进行调试。选择VS菜单【Debug】 ->【Quick Debugwith GDB】。

需要注意的地方,已经在上图中标红框。这里简单地解释一下:

如果你安装了交叉编译环境Target可以选择MinGW/Cygwin,否则就选择远程Linux系统。这里如果不存在一个ssh连接,则需要创建一个。

Debugged program是需要设置的被调试程序的路径,位于远程Linux机器上。

Arguments是该调试程序需要设置的命令行参数,如果被调试程序不需要命令行参数可以不设置。

Working directory是被调试程序运行的工作目录。

另外建议勾选上Initial breakpoint in main,这样启动调试时,程序就会停在程序入口处。

这样,我们就可以利用VS强大的功能去查看程序的各种状态了,常用的面板,如【内存】【线程】【观察】【堆栈】【GDB Session】【断点】等窗口位于VS 菜单【Debug】->【Windows】菜单下,注意,有些窗口只有在调试状态下才可见。这里有两个值得强调一下的功能是:

  1. GDB Session窗口,在这个窗口里面可以像原来直接使用gdb调试一样输入gdb指令来进行调试。

img

  1. SSH console窗口,这个窗口类似一个远程操作Linux系统的应用程序如xshell、SecureCRT。

现在还剩下一个问题,就是我们虽然在调试时可视化地远程查看一个Linux进程的状态信息,但很多类型的定义和什么却无法看到。解决这个问题的方法就是你可以先在VS里面建立一个工程,导入你要调试的程序的源代码目录。然后利用方法一或者方法二去启动调试程序。这个时候你想查看某个类型的定义或什么只要利用Visual Assit的查看源码功能即可,快捷键是Alt + G。

需要注意的时:同时安装了Visual Assist和VisualGDB后,后者也会提供一个go按钮去查找源码定义,但这个功能远不如Visual Assist按钮好用,我们可以禁用掉它来使用Visual Assist的Go功能。禁用方法,打开菜单:【Tools】->【Option...】:

然后重启VS即可。

到这里,既可以查看源码,也可以调试程序了。

VisualGDB 下载地址:

链接:https://share.weiyun.com/57aGHLM 密码:kj9ahs


如何使用VisualStudio管理和阅读开源项目代码

如何使用 Visual Studio 管理和阅读开源项目代码

对于 Linux C/C++ 项目,虽然我们在 Linux 系统中使用 gdb 去调试,但是通常情况下对于 C/C++ 项目笔者一般习惯使用 Visual Studio 去做项目管理,Visual Studio 提供了强大的 C/C++ 项目开发和管理能力。这里以 redis 源码为例,介绍一下如何将这种开源项目整体添加到 Visual Studio 的解决方案中去。

  1. 启动 Visual Studio 新建一个空的 Win32 控制台程序。(工程建好后,关闭该工程防止接下来的步骤中文件占用导致的无法移动。)

img

\2. 这样会在 redis 源码目录下会根据你设置的名称生成一个文件夹(这里是 redis-4.0.1),将该文件夹中所有文件拷贝到 redis 源码根目录,然后删掉生成的这个文件夹。

img

img

\3. 再次用 Visual Studio 打开 redis-4.0.1.sln 文件,然后在解决方案资源管理器视图中点击显示所有文件按钮并保持该按钮选中。(如果找不到解决方案资源管理器视图,可以在【视图】菜单中打开,快捷键 Ctrl + Alt + L。)

img

\4. 然后选中所有需要添加到解决方案中的文件,右键选择菜单【包括在项目中】即可,如果文件比较多,Visual Studio 可能需要一会儿才能完成,为了减少等待时间,读者也可以一批一批的添加。

img

5.接着选择【文件】菜单【全部保存】菜单项保存即可(快捷键 Ctrl + Shift + S )。

最终效果如下图所示:

img

这样我们就能利用 Visual Studio 强大的功能管理和阅读我们的源码了。

这里要提醒一下读者:C/C++ 开源项目中一般会使用各种宏去条件编译一些代码,实际生成的二进制文件中不一定包含这些代码,所以在 Visual Studio 中看到某段代码的行号与实际在 gdb 中调试的代码行号不一定相同,在给某一行代码设置断点时请以 gdb 中 list 命令看到的代码行号为准。

利用cmake工具生成VisualStudio工程文件

利用 cmake 工具生成 Visual Studio 工程文件

对于习惯了 Visual Studio 强大的管理项目、编码和调试功能的读者来说,在 Linux 下使用 gcc/g++ 编译、使用 gdb 调试是一件何其痛苦的事情,对于大多数的开源 C/C++ 项目,如果我们不在意 Windows 和 Linux 在一些底层 API 接口上的使用差别,想熟悉该项目的执行脉络和原理,在 Windows 上使用 Visual Studio 调试该项目也未尝不可。凡是可以使用 CMake 工具编译的 Linux 程序(即提供了 CMakeLists.txt 文件),我们同样也可以利用 CMake 工具生成 Windows 上的 Visual Studio 工程文件。

这里我们以著名的开源网络库 libuv 为例。

从 libuv 的官方地址提供的下载链接:https://dist.libuv.org/dist/ 下载最新的 libuv 的源码得到文件 libuv-v1.31.0.tar.gz(笔者写作此书时,libuv 最新版本是 1.31.0),解压该文件。作者的机器上我将代码解压至 F:\mycode\libuv-v1.31.0\ ,解压后的目录中确实存在一个 CMakeLists.txt 文件,如下图所示:

img

启动 Windows 上的 CMake 图形化工具(cmake-gui),按下图进行设置:

img

设置完成之后,点击界面上的Configure 按钮,会提示 vsprojects 目录不存在,提示是否创建,我们点击 Yes 进行创建。

img

如果您的机器上安装了多个版本的Visual Studio,接下来会弹窗对话框让我们选择要生成的工程文件对应的 Visual Studio 版本号。读者可以根据自己的实际情况按需选择。我这里选择 Visual Studio 2019。

img

img

点击 Finish 按钮后开始启动 CMake 的检测和配置工作。等待一会儿,CMake 底部的输出框中提示 “Configuring Done” 表示配置工作已经完成。

img

接下来点击 Generate 按钮即可生成所选版本的 Visual Studio 工程文件,生成的文件位于 vsprojects 目录。

img

我们可以在界面上点击按钮 Open Project 按钮直接打开工程文件,也可以找到对应目录下的 libuv.sln 打开。

打开后如下图所示:

img

接下来,我们就可以使用 Visual Studio 愉快地进行编译和调试了。

让我们再深入聊一下上述过程:在点击 Configure 按钮之后,和在 Linux 下执行 cmake 命令一样,CMake 工具也是在检测所在的系统环境是否匹配 CMakeLists.txt 中定义的各种环境,本质上是生成了一份可以在 Windows 上编译和运行的代码(也就是说该源码支持在 Windows 上运行) 。因此,对于很多虽然提供了 CMakeLists.txt 文件但并不支持在 Windows 上运行的的 Linux 工程,虽然利用上述方法也能最终生成 Visual Studio 工程文件,但是这些文件并不能在 Windows 上直接无错编译和调试。

由于不同的 CMake 版本支持的 CMakeLists.txt 中的语法可能略有细微差别,有些 CMakeLists.txt 文件在使用上述方法 configure 时可能会产生一些错误,需要读者做些修改才能通过。

后台C++开发你一定要知道的条件变量

后台C++开发你一定要知道的条件变量

今天因为工作需要,需要帮同事用C语言(不是C++)写一个生产者消费者的任务队列工具库,考虑到不能使用任何第三库和C++的任何特性,所以我将任务队列做成一个链表,生产者在队列尾部加入任务,消费者在队列头部取出任务。很快就写好了,代码如下:

/**
 *  线程池工具, ctrip_thread_pool.h
 *  zhangyl 2018.03.23
 */

#ifndef __CTRIP_THREAD_POOL_H__
#define __CTRIP_THREAD_POOL_H__

#include <pthread.h>

#ifndef NULL
#define NULL 0
#endif

#define PUBLIC 

PUBLIC struct ctrip_task
{
    struct ctrip_task*   pNext;
    int                  value;
};

struct ctrip_thread_info
{
    //线程退出标志
    int                    thread_running;
    int                    thread_num;
    int                    tasknum;
    struct ctrip_task*     tasks;
    pthread_t*             threadid;
    pthread_mutex_t        mutex;
    pthread_cond_t         cond;
};

/* 初始化线程池线程数目
 * @param thread_num 线程数目, 默认为8个
 */
PUBLIC void  ctrip_init_thread_pool(int thread_num);

/* 销毁线程池
 */
PUBLIC void  ctrip_destroy_thread_pool();

/**向任务池中增加一个任务
 * @param t 需要增加的任务
 */
PUBLIC void ctrip_thread_pool_add_task(struct ctrip_task* t);

/**从任务池中取出一个任务
 * @return 返回得到的任务
 */
struct ctrip_task* ctrip_thread_pool_retrieve_task();

/**执行任务池中的任务
 * @param t 需要执行的任务
 */
PUBLIC void ctrip_thread_pool_do_task(struct ctrip_task* t);

/**线程函数
 * @param thread_param 线程参数
 */
void* ctrip_thread_routine(void* thread_param);

#endif //!__CTRIP_THREAD_POOL_H__
/**
 *  线程池工具, ctrip_thread_pool.c
 *  zhangyl 2018.03.23
 */

#include "ctrip_thread_pool.h"
#include <stdio.h>
#include <stdlib.h>

struct ctrip_thread_info g_threadinfo;
int thread_running = 0;

void ctrip_init_thread_pool(int thread_num)
{
    if (thread_num <= 0)
        thread_num = 5;

    pthread_mutex_init(&g_threadinfo.mutex, NULL);
    pthread_cond_init(&g_threadinfo.cond, NULL);

    g_threadinfo.thread_num = thread_num;
    g_threadinfo.thread_running = 1;
    g_threadinfo.tasknum = 0;
    g_threadinfo.tasks = NULL;
    thread_running = 1;

    g_threadinfo.threadid = (pthread_t*)malloc(sizeof(pthread_t) * thread_num);

    int i;
    for (i = 0; i < thread_num; ++i)
    {
        pthread_create(&g_threadinfo.threadid[i], NULL, ctrip_thread_routine, NULL);
    }
}

void ctrip_destroy_thread_pool()
{
    g_threadinfo.thread_running = 0;
    thread_running = 0;
    pthread_cond_broadcast(&g_threadinfo.cond);

    int i;
    for (i = 0; i < g_threadinfo.thread_num; ++i)
    {
        pthread_join(g_threadinfo.threadid[i], NULL);
    }

    free(g_threadinfo.threadid);

    pthread_mutex_destroy(&g_threadinfo.mutex);
    pthread_cond_destroy(&g_threadinfo.cond);
}

void ctrip_thread_pool_add_task(struct ctrip_task* t)
{
    if (t == NULL)
        return;

    pthread_mutex_lock(&g_threadinfo.mutex);
    
    struct ctrip_task* head = g_threadinfo.tasks;
    if (head == NULL)
        g_threadinfo.tasks = t;
    else
    {
        while (head->pNext != NULL)
        {
            head = head->pNext;
        }

        head->pNext = t;
    }
    
    ++g_threadinfo.tasknum;
    //当有变化后,使用signal通知wait函数
    pthread_cond_signal(&g_threadinfo.cond);
    pthread_mutex_unlock(&g_threadinfo.mutex);
}


struct ctrip_task* ctrip_thread_pool_retrieve_task()
{
    struct ctrip_task* head = g_threadinfo.tasks;
    if (head != NULL)
    {
        g_threadinfo.tasks = head->pNext;
        --g_threadinfo.tasknum;
        printf("retrieve a task, task value is [%d]\n", head->value);
        return head;
    }

    printf("no task\n");

    return NULL;
}

void* ctrip_thread_routine(void* thread_param)
{
    printf("thread NO.%d start.\n", (int)pthread_self());

    while (thread_running/*g_threadinfo.thread_running*/)
    {
        struct ctrip_task* current = NULL;
        
        pthread_mutex_lock(&g_threadinfo.mutex);

        while (g_threadinfo.tasknum <= 0)
        {
            //如果获得了互斥锁,但是条件不合适的话,wait会释放锁,不往下执行。
            //当变化后,条件合适,将直接获得锁。
            pthread_cond_wait(&g_threadinfo.cond, &g_threadinfo.mutex);

            if (!g_threadinfo.thread_running)
                break;

            current = ctrip_thread_pool_retrieve_task();

            if (current != NULL)
                break;
        }// end inner-while-loop

        pthread_mutex_unlock(&g_threadinfo.mutex);

        ctrip_thread_pool_do_task(current);
    }// end outer-while-loop

    printf("thread NO.%d exit.\n", (int)pthread_self());
}


void ctrip_thread_pool_do_task(struct ctrip_task* t)
{
    if (t == NULL)
        return;


    //TODO: do your work here
    printf("task value is [%d]\n", t->value);
    //TODO:如果t需要释放,记得在这里释放
}

测试代码如下:

// ctrip_thread_pool.cpp : Defines the entry point for the console application.
//

//#include "stdafx.h"
#include "ctrip_thread_pool.h"
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char* argv[])
{
    ctrip_init_thread_pool(5);
    struct ctrip_task* task = NULL;
    int i;
    for (i = 0; i < 100; ++i)
    {
        task = (struct ctrip_task*)malloc(sizeof(struct ctrip_task));
        task->value = i + 1;
        task->pNext = NULL;
        printf("add task, task value [%d]\n", task->value);
        ctrip_thread_pool_add_task(task);
    }

    sleep(10);

    ctrip_destroy_thread_pool();
    
    return 0;
}

代码很快就写好了,但是每次程序只能执行前几个加到任务池子里面的任务,导致池子有不少任务积累在池子里面。甚是奇怪,我也看了半天才看出结果。聪明的你,能看出上述代码为啥只能执行加到池子里面的前几个任务?先不要看答案,自己想一会儿。

linux条件变量是做后台开发必须熟练掌握的基础知识,而条件变量使用存在以下几个非常让人迷惑的地方,讲解如下

第一、必须要结合一个互斥体一起使用。使用流程如下:

pthread_mutex_lock(&g_threadinfo.mutex)           
pthread_cond_wait(&g_threadinfo.cond, &g_threadinfo.mutex);
pthread_mutex_unlock(&g_threadinfo.mutex);

上面的代码,我们分为一二三步,当条件不满足是pthread_cond_wait会挂起线程,但是不知道你有没有注意到,如果在第二步挂起线程的话,第一步的mutex已经被上锁,谁来解锁?mutex的使用原则是谁上锁谁解锁,所以不可能在其他线程来给这个mutex解锁,但是这个线程已经挂起了,这就死锁了。所以pthread_cond_wait在挂起之前,额外做的一个事情就是给绑定的mutex解锁。反过来,如果条件满足,pthread_cond_wait不挂起线程,pthread_cond_wait将什么也不做,这样就接着走pthread_mutex_unlock解锁的流程。而在这个加锁和解锁之间的代码就是我们操作受保护资源的地方。

第二,不知道你有没有注意到pthread_cond_wait是放在一个while循环里面的:

        pthread_mutex_lock(&g_threadinfo.mutex);

        while (g_threadinfo.tasknum <= 0)
        {
            //如果获得了互斥锁,但是条件不合适的话,wait会释放锁,不往下执行。
            //当变化后,条件合适,将直接获得锁。
            pthread_cond_wait(&g_threadinfo.cond, &g_threadinfo.mutex);

            if (!g_threadinfo.thread_running)
                break;

            current = ctrip_thread_pool_retrieve_task();

            if (current != NULL)
                break;
        }// end inner-while-loop

        pthread_mutex_unlock(&g_threadinfo.mutex);

注意,我说的是内层的while循环,不是外层的。pthread_cond_wait一定要放在一个while循环里面吗?一定要的。这里有一个非常重要的关于条件变量的基础知识,叫条件变量的虚假唤醒(spurious wakeup),那啥叫条件变量的虚假唤醒呢?假设pthread_cond_wait不放在这个while循环里面,正常情况下,pthread_cond_wait因为条件不满足,挂起线程。然后,外部条件满足以后,调用pthread_cond_signal或pthread_cond_broadcast来唤醒挂起的线程。这没啥问题。但是条件变量可能在某些情况下也被唤醒,这个时候pthread_cond_wait处继续往下执行,但是这个时候,条件并不满足(比如任务队列中仍然为空)。这种唤醒我们叫“虚假唤醒”。为了避免虚假唤醒时,做无意义的动作,我们将pthread_cond_wait放到while循环条件中,这样即使被虚假唤醒了,由于while条件(比如任务队列是否为空,资源数量是否大于0)仍然为true,导致线程进行继续挂起。有人说条件变量是最不可能用错的线程之间同步技术,我却觉得这是最容易使用错误的线程之间同步技术。

上述代码存在的问题是,只考虑了任务队列开始为空,生产者后来添加了任务,条件变量被唤醒,然后消费者取任务执行的逻辑。假如一开始池中就有任务呢?这个原因导致,只有开始的几个添加到任务队列中任务被执行。因为一旦任务队列不为空。内层while循环条件将不再满足,导致消费者线程不再从任务队列中取任务消费。正确的代码如下:

/**
 *  线程池工具, ctrip_thread_pool.c(修正后的代码)
 *  zhangyl 2018.03.23
 */

#include "ctrip_thread_pool.h"
#include <stdio.h>
#include <stdlib.h>

struct ctrip_thread_info g_threadinfo;

void ctrip_init_thread_pool(int thread_num)
{
    if (thread_num <= 0)
        thread_num = 5;

    pthread_mutex_init(&g_threadinfo.mutex, NULL);
    pthread_cond_init(&g_threadinfo.cond, NULL);

    g_threadinfo.thread_num = thread_num;
    g_threadinfo.thread_running = 1;
    g_threadinfo.tasknum = 0;
    g_threadinfo.tasks = NULL;

    g_threadinfo.threadid = (pthread_t*)malloc(sizeof(pthread_t) * thread_num);

    int i;
    for (i = 0; i < thread_num; ++i)
    {
        pthread_create(&g_threadinfo.threadid[i], NULL, ctrip_thread_routine, NULL);
    }
}

void ctrip_destroy_thread_pool()
{
    g_threadinfo.thread_running = 0;
    pthread_cond_broadcast(&g_threadinfo.cond);

    int i;
    for (i = 0; i < g_threadinfo.thread_num; ++i)
    {
        pthread_join(g_threadinfo.threadid[i], NULL);
    }

    free(g_threadinfo.threadid);

    pthread_mutex_destroy(&g_threadinfo.mutex);
    pthread_cond_destroy(&g_threadinfo.cond);
}

void ctrip_thread_pool_add_task(struct ctrip_task* t)
{
    if (t == NULL)
        return;

    pthread_mutex_lock(&g_threadinfo.mutex);
    
    struct ctrip_task* head = g_threadinfo.tasks;
    if (head == NULL)
        g_threadinfo.tasks = t;
    else
    {
        while (head->pNext != NULL)
        {
            head = head->pNext;
        }

        head->pNext = t;
    }
    
    ++g_threadinfo.tasknum;
    //当有变化后,使用signal通知wait函数
    pthread_cond_signal(&g_threadinfo.cond);
    pthread_mutex_unlock(&g_threadinfo.mutex);
}


struct ctrip_task* ctrip_thread_pool_retrieve_task()
{
    struct ctrip_task* head = g_threadinfo.tasks;
    if (head != NULL)
    {
        g_threadinfo.tasks = head->pNext;
        --g_threadinfo.tasknum;
        printf("retrieve a task, task value is [%d]\n", head->value);
        return head;
    }

    printf("no task\n");

    return NULL;
}

void* ctrip_thread_routine(void* thread_param)
{
    printf("thread NO.%d start.\n", (int)pthread_self());

    while (g_threadinfo.thread_running)
    {
        struct ctrip_task* current = NULL;
        
        pthread_mutex_lock(&g_threadinfo.mutex);

        while (g_threadinfo.tasknum <= 0)
        {
            //如果获得了互斥锁,但是条件不合适的话,wait会释放锁,不往下执行。
            //当变化后,条件合适,将直接获得锁。
            pthread_cond_wait(&g_threadinfo.cond, &g_threadinfo.mutex);

            if (!g_threadinfo.thread_running)
                break;

        }// end inner-while-loop

        current = ctrip_thread_pool_retrieve_task();
        pthread_mutex_unlock(&g_threadinfo.mutex);

        ctrip_thread_pool_do_task(current);
    }// end outer-while-loop

    printf("thread NO.%d exit.\n", (int)pthread_self());
}


void ctrip_thread_pool_do_task(struct ctrip_task* t)
{
    if (t == NULL)
        return;


    //TODO: do your work here
    printf("task value is [%d]\n", t->value);
    //TODO:如果t需要释放,记得在这里释放
}

ok,不知道你有没有看明白呀?


整型变量赋值是原子操作吗?

整型变量赋值是原子操作吗?

整型变量赋值操作不是原子操作

那么为什么整型变量的操作不是原子性的呢?常见的整型变量操作有如下几种情况:

给整型变量赋值一个确定的值,如

int a = 1;

这条指令操作一般是原子的,因为对应着一条计算机指令,cpu将立即数1搬运到变量a的内存地址中即可,汇编指令如下:

mov dword ptr [a], 2  

然后这确是最不常见的情形,由于现代编译器一般有优化策略,如果变量a的值在编译期间就可以计算出来(例如这里的例子中a的值就是1),那么a这个变量本身在正式版本的软件中(release版)就很有可能被编译器优化掉,使用a的地方,直接使用常量1来代替。所以实际的执行指令中,这样的指令存在的可能性比较低。

变量自身增加或者减去一个值,如

a ++;

从C/C++语法的级别来看,这是一条语句,是原子的;但是从实际执行的二进制指令来看,也不是原子的,其一般对应三条指令,首先将变量a对应的内存值搬运到某个寄存器(如eax)中,然后将该寄存器中的值自增1,再将该寄存器中的值搬运回a的内存中:

mov eax, dword ptr [a]  
inc eax
mov dword ptr [a], eax

现在假设a的值是0,有两个线程,每个线程对变量a的值递增1,我们预想的结果应该是2,可实际运行的结果可能是1!是不是很奇怪?分析如下:

int a = 0;

//线程1
void thread_func1()
{
	a ++;
}

//线程2
void thread_func2()
{
	a ++;
}

我们预想的结果是线程1和线程2的三条指令各自执行,最终a的值为2,但是由于操作系统线程调度的不确定性,线程1执行完指令①和②后,eax寄存器中的值为1,此时操作系统切换到线程2执行,执行指令③④⑤,此时eax的值变为1;接着操作系统切回线程1继续执行,执行指令⑦,得到a的最终结果1。

  • 把一个变量的值赋值给另外一个变量,或者把一个表达式的值赋值给另外一个变量,如
  int a = b;

从C/C++语法的级别来看,这是也是一条语句,是原子的;但是从实际执行的二进制指令来看,由于现代计算机CPU架构体系的限制,数据不可以直接从内存搬运到另外一块内存,必须借助寄存器中断,这条语句一般对应两条计算机指令,即将变量b的值搬运到某个寄存器(如eax)中,再从该寄存器搬运到变量a的内存地址:

  mov eax, dword ptr [b]  
  mov dword ptr [a], eax 

既然是两条指令,那么多个线程在执行这两条指令时,某个线程可能会在第一条指令执行完毕后被剥夺CPU时间片,切换到另外一个线程而产生不确定的情况。这和上一种情况类似,就不再详细分析了。

说点题外话,网上很多人强调某些特殊的整型数值类型(如bool类型)的操作是原子的,这是由于,某些CPU生产商开始有意识地从硬件平台保证这一类操作的原子性,但这并不是每一种类型的CPU架构都支持,在这一事实成为标准之前,我们在多线程操作整型时还是老老实实使用下文介绍的原子操作或线程同步技术来对这些数据类型进行保护。


bind函数重难点解析

bind 函数重难点解析

bind 函数如何选择绑定地址

bind 函数的基本用法如下:

struct sockaddr_in bindaddr;
bindaddr.sin_family = AF_INET;
bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
bindaddr.sin_port = htons(3000);
if (bind(listenfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
{
    std::cout << "bind listen socket error." << std::endl;
    return -1;
}

其中 bind 的地址我们使用了一个宏叫 INADDR_ANY ,关于这个宏的解释如下:

If an application does not care what local address is assigned, 
specify the constant value INADDR_ANY for an IPv4 local address
or the constant value in6addr_any for an IPv6 local address 
in the sa_data member of the name parameter. This allows the 
underlying service provider to use any appropriate network address,
potentially simplifying application programming in the presence of 
multihomed hosts (that is, hosts that have more than one network 
interface and address).

意译一下:

如果应用程序不关心bind绑定的ip地址,可以使用INADDR_ANY(如果是IPv6,
则对应in6addr_any),这样底层的(协议栈)服务会自动选择一个合适的ip地址,
这样使在一个有多个网卡机器上选择ip地址问题变得简单。

也就是说 INADDR_ANY 相当于地址 0.0.0.0。可能读者还是不太明白我想表达什么。这里我举个例子,假设我们在一台机器上开发一个服务器程序,使用 bind 函数时,我们有多个ip 地址可以选择。首先,这台机器对外访问的ip地址是120.55.94.78,这台机器在当前局域网的地址是192.168.1.104;同时这台机器有本地回环地址127.0.0.1。

如果你指向本机上可以访问,那么你 bind 函数中的地址就可以使用127.0.0.1; 如果你的服务只想被局域网内部机器访问,bind 函数的地址可以使用192.168.1.104;如果 希望这个服务可以被公网访问,你就可以使用地址0.0.0.0或 INADDR_ANY。

bind 函数端口号问题

网络通信程序的基本逻辑是客户端连接服务器,即从客户端的地址:端口连接到服务器地址:端口上,以 4.2 小节中的示例程序为例,服务器端的端口号使用 3000,那客户端连接时的端口号是多少呢?TCP 通信双方中一般服务器端端口号是固定的,而客户端端口号是连接发起时由操作系统随机分配的(不会分配已经被占用的端口)。端口号是一个 C short 类型的值,其范围是0~65535,知道这点很重要,所以我们在编写压力测试程序时,由于端口数量的限制,在某台机器上网卡地址不变的情况下压力测试程序理论上最多只能发起六万五千多个连接。注意我说的是理论上,在实际情况下,由于当时的操作系统很多端口可能已经被占用,实际可以使用的端口比这个更少,例如,一般规定端口号在1024以下的端口是保留端口,不建议用户程序使用。而对于 Windows 系统,MSDN 甚至明确地说:

On Windows Vista and later, the dynamic client port range is a value between 49152 and 65535. This is a change from Windows Server 2003 and earlier where the dynamic client port range was a value between 1025 and 5000. Vista 及以后的Windows,可用的动态端口范围是49152~65535,而 Windows Server及更早的系统,可以的动态端口范围是1025~5000。(你可以通过修改注册表来改变这一设置,参考网址:https://docs.microsoft.com/en-us/windows/desktop/api/winsock/nf-winsock-bind)

如果将 bind 函数中的端口号设置成0,那么操作系统会随机给程序分配一个可用的侦听端口,当然服务器程序一般不会这么做,因为服务器程序是要对外服务的,必须让客户端知道确切的ip地址和端口号。

很多人觉得只有服务器程序可以调用 bind 函数绑定一个端口号,其实不然,在一些特殊的应用中,我们需要客户端程序以指定的端口号去连接服务器,此时我们就可以在客户端程序中调用 bind 函数绑定一个具体的端口。

我们用代码来实际验证一下上路所说的,为了能看到连接状态,我们将客户端和服务器关闭socket的代码注释掉,这样连接会保持一段时间。

  • 情形一:客户端代码不绑定端口

修改后的服务器代码如下:

/**
 * TCP服务器通信基本流程
 * zhangyl 2018.12.13
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <vector>

int main(int argc, char* argv[])
{
    //1.创建一个侦听socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1)
    {
        std::cout << "create listen socket error." << std::endl;
        return -1;
    }

    //2.初始化服务器地址
    struct sockaddr_in bindaddr;
    bindaddr.sin_family = AF_INET;
    bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    bindaddr.sin_port = htons(3000);
    if (bind(listenfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
    {
        std::cout << "bind listen socket error." << std::endl;
        return -1;
    }

    //3.启动侦听
    if (listen(listenfd, SOMAXCONN) == -1)
    {
        std::cout << "listen error." << std::endl;
        return -1;
    }

    //记录所有客户端连接的容器
    std::vector<int> clientfds;
    while (true)
    {
        struct sockaddr_in clientaddr;
        socklen_t clientaddrlen = sizeof(clientaddr);
        //4. 接受客户端连接
        int clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientaddrlen);
        if (clientfd != -1)
        {             
            char recvBuf[32] = {0};
            //5. 从客户端接受数据
            int ret = recv(clientfd, recvBuf, 32, 0);
            if (ret > 0) 
            {
                std::cout << "recv data from client, data: " << recvBuf << std::endl;
                //6. 将收到的数据原封不动地发给客户端
                ret = send(clientfd, recvBuf, strlen(recvBuf), 0);
                if (ret != strlen(recvBuf))
                    std::cout << "send data error." << std::endl;

                std::cout << "send data to client successfully, data: " << recvBuf << std::endl;
            } 
            else 
            {
                std::cout << "recv data error." << std::endl;
            }

            //close(clientfd);
            clientfds.push_back(clientfd);
        }
    }

    //7.关闭侦听socket
    close(listenfd);

    return 0;
}

修改后的客户端代码如下:

/**
 * TCP客户端通信基本流程
 * zhangyl 2018.12.13
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>

#define SERVER_ADDRESS "127.0.0.1"
#define SERVER_PORT     3000
#define SEND_DATA       "helloworld"

int main(int argc, char* argv[])
{
    //1.创建一个socket
    int clientfd = socket(AF_INET, SOCK_STREAM, 0);
    if (clientfd == -1)
    {
        std::cout << "create client socket error." << std::endl;
        return -1;
    }

    //2.连接服务器
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
    serveraddr.sin_port = htons(SERVER_PORT);
    if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
    {
        std::cout << "connect socket error." << std::endl;
        return -1;
    }

    //3. 向服务器发送数据
    int ret = send(clientfd, SEND_DATA, strlen(SEND_DATA), 0);
    if (ret != strlen(SEND_DATA))
    {
        std::cout << "send data error." << std::endl;
        return -1;
    }

    std::cout << "send data successfully, data: " << SEND_DATA << std::endl;

    //4. 从客户端收取数据
    char recvBuf[32] = {0};
    ret = recv(clientfd, recvBuf, 32, 0);
    if (ret > 0) 
    {
        std::cout << "recv data successfully, data: " << recvBuf << std::endl;
    } 
    else 
    {
        std::cout << "recv data error, data: " << recvBuf << std::endl;
    }

    //5. 关闭socket
    //close(clientfd);
    //这里仅仅是为了让客户端程序不退出
    while (true) 
    {
        sleep(3);
    }

    return 0;
}

将程序编译好后(编译方法和上文一样),我们先启动server,再启动三个客户端。然后通过 lsof 命令查看当前机器上的 TCP 连接信息,为了更清楚地显示结果,已经将不相关的连接信息去掉了,结果如下所示:

[root@localhost ~]# lsof -i -Pn
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
server   1445 root    3u  IPv4  21568      0t0  TCP *:3000 (LISTEN)
server   1445 root    4u  IPv4  21569      0t0  TCP 127.0.0.1:3000->127.0.0.1:40818 (ESTABLISHED)
server   1445 root    5u  IPv4  21570      0t0  TCP 127.0.0.1:3000->127.0.0.1:40820 (ESTABLISHED)
server   1445 root    6u  IPv4  21038      0t0  TCP 127.0.0.1:3000->127.0.0.1:40822 (ESTABLISHED)
client   1447 root    3u  IPv4  21037      0t0  TCP 127.0.0.1:40818->127.0.0.1:3000 (ESTABLISHED)
client   1448 root    3u  IPv4  21571      0t0  TCP 127.0.0.1:40820->127.0.0.1:3000 (ESTABLISHED)
client   1449 root    3u  IPv4  21572      0t0  TCP 127.0.0.1:40822->127.0.0.1:3000 (ESTABLISHED)

上面的结果显示,server 进程(进程 ID 是 1445)在 3000 端口开启侦听,有三个 client 进程(进程 ID 分别是1447、1448、1449)分别通过端口号 40818、40820、40822 连到 server 进程上的,作为客户端的一方,端口号是系统随机分配的。

  • 情形二:客户端绑定端口号 0

服务器端代码保持不变,我们修改下客户端代码:

  /**
   * TCP服务器通信基本流程
   * zhangyl 2018.12.13
   */
  #include <sys/types.h> 
  #include <sys/socket.h>
  #include <arpa/inet.h>
  #include <unistd.h>
  #include <iostream>
  #include <string.h>
  
  #define SERVER_ADDRESS "127.0.0.1"
  #define SERVER_PORT     3000
  #define SEND_DATA       "helloworld"
  
  int main(int argc, char* argv[])
  {
    //1.创建一个socket
    int clientfd = socket(AF_INET, SOCK_STREAM, 0);
    if (clientfd == -1)
    {
        std::cout << "create client socket error." << std::endl;
        return -1;
    }
  
    struct sockaddr_in bindaddr;
    bindaddr.sin_family = AF_INET;
    bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    //将socket绑定到0号端口上去
    bindaddr.sin_port = htons(0);
    if (bind(clientfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
    {
        std::cout << "bind socket error." << std::endl;
        return -1;
    }
  
    //2.连接服务器
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
    serveraddr.sin_port = htons(SERVER_PORT);
    if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
    {
        std::cout << "connect socket error." << std::endl;
        return -1;
    }
  
    //3. 向服务器发送数据
    int ret = send(clientfd, SEND_DATA, strlen(SEND_DATA), 0);
    if (ret != strlen(SEND_DATA))
    {
        std::cout << "send data error." << std::endl;
        return -1;
    }
  
    std::cout << "send data successfully, data: " << SEND_DATA << std::endl;
  
    //4. 从客户端收取数据
    char recvBuf[32] = {0};
    ret = recv(clientfd, recvBuf, 32, 0);
    if (ret > 0) 
    {
        std::cout << "recv data successfully, data: " << recvBuf << std::endl;
    } 
    else 
    {
        std::cout << "recv data error, data: " << recvBuf << std::endl;
    }
  
    //5. 关闭socket
    //close(clientfd);
    //这里仅仅是为了让客户端程序不退出
    while (true) 
    {
        sleep(3);
    }
  
    return 0;
  }

我们再次编译客户端程序,并启动三个 client 进程,然后用 lsof 命令查看机器上的 TCP 连接情况,结果如下所示:

  [root@localhost ~]# lsof -i -Pn
  COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
  server   1593 root    3u  IPv4  21807      0t0  TCP *:3000 (LISTEN)
  server   1593 root    4u  IPv4  21808      0t0  TCP 127.0.0.1:3000->127.0.0.1:44220 (ESTABLISHED)
  server   1593 root    5u  IPv4  19311      0t0  TCP 127.0.0.1:3000->127.0.0.1:38990 (ESTABLISHED)
  server   1593 root    6u  IPv4  21234      0t0  TCP 127.0.0.1:3000->127.0.0.1:42365 (ESTABLISHED)
  client   1595 root    3u  IPv4  22626      0t0  TCP 127.0.0.1:44220->127.0.0.1:3000 (ESTABLISHED)
  client   1611 root    3u  IPv4  21835      0t0  TCP 127.0.0.1:38990->127.0.0.1:3000 (ESTABLISHED)
  client   1627 root    3u  IPv4  21239      0t0  TCP 127.0.0.1:42365->127.0.0.1:3000 (ESTABLISHED)

通过上面的结果,我们发现三个 client 进程使用的端口号仍然是系统随机分配的,也就是说绑定 0 号端口和没有绑定效果是一样的。

  • 情形三:客户端绑定一个固定端口

我们这里使用 20000 端口,当然读者可以根据自己的喜好选择,只要保证所选择的端口号当前没有被其他程序占用即可,服务器代码保持不变,客户端绑定代码中的端口号从 0 改成 20000。这里为了节省篇幅,只贴出修改处的代码:

  struct sockaddr_in bindaddr;
  bindaddr.sin_family = AF_INET;
  bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  //将socket绑定到20000号端口上去
  bindaddr.sin_port = htons(20000);
  if (bind(clientfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
  {
      std::cout << "bind socket error." << std::endl;
      return -1;
  }

再次重新编译程序,先启动一个客户端后,我们看到此时的 TCP 连接状态:

  [root@localhost testsocket]# lsof -i -Pn
  COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
  server   1676 root    3u  IPv4  21933      0t0  TCP *:3000 (LISTEN)
  server   1676 root    4u  IPv4  21934      0t0  TCP 127.0.0.1:3000->127.0.0.1:20000 (ESTABLISHED)
  client   1678 root    3u  IPv4  21336      0t0  TCP 127.0.0.1:20000->127.0.0.1:3000 (ESTABLISHED)

通过上面的结果,我们发现 client 进程确实使用 20000 号端口连接到 server 进程上去了。这个时候如果我们再开启一个 client 进程,我们猜想由于端口号 20000 已经被占用,新启动的 client 会由于调用 bind 函数出错而退出,我们实际验证一下:

  [root@localhost testsocket]# ./client 
  bind socket error.
  [root@localhost testsocket]# 

结果确实和我们预想的一样。

在技术面试的时候,有时候面试官会问 TCP 网络通信的客户端程序中的 socket 是否可以调用 bind 函数,相信读到这里,聪明的读者已经有答案了。

另外,Linux 的 nc 命令有个 -p 选项(字母 p 是小写),这个选项的作用就是 nc 在模拟客户端程序时,可以使用指定端口号连接到服务器程序上去,实现原理相信读者也明白了。我们还是以上面的服务器程序为例,这个我们不用我们的 client 程序,改用 nc 命令来模拟客户端。在 shell 终端输入:

[root@localhost testsocket]# nc -v -p 9999 127.0.0.1 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:3000.
My name is zhangxf
My name is zhangxf

-v 选项表示输出 nc 命令连接的详细信息,这里连接成功以后,会输出“Ncat: Connected to 127.0.0.1:3000.” 提示已经连接到服务器的 3000 端口上去了。

-p 选项的参数值是 9999 表示,我们要求 nc 命令本地以端口号 9999 连接服务器,注意不要与端口号 3000 混淆,3000 是服务器的侦听端口号,也就是我们的连接的目标端口号,9999 是我们客户端使用的端口号。我们用 lsof 命令来验证一下我们的 nc 命令是否确实以 9999 端口号连接到 server 进程上去了。

[root@localhost testsocket]# lsof -i -Pn
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
server   1676 root    3u  IPv4  21933      0t0  TCP *:3000 (LISTEN)
server   1676 root    7u  IPv4  22405      0t0  TCP 127.0.0.1:3000->127.0.0.1:9999 (ESTABLISHED)
nc       2005 root    3u  IPv4  22408      0t0  TCP 127.0.0.1:9999->127.0.0.1:3000 (ESTABLISHED)

结果确实如我们期望的一致。

当然,我们用 nc 命令连接上 server 进程以后,我们还给服务器发了一条消息"My name is zhangxf",server 程序收到消息后把这条消息原封不动地返还给我们,以下是 server 端运行结果:

[root@localhost testsocket]# ./server   
recv data from client, data: My name is zhangxf

send data to client successfully, data: My name is zhangxf

关于 lsof 和 nc 命令我们会在后面的系列文章中详细讲解。


connect函数在阻塞和非阻塞模式下的行为

connect 函数在阻塞和非阻塞模式下的行为

在 socket 是阻塞模式下 connect 函数会一直到有明确的结果才会返回(或连接成功或连接失败),如果服务器地址“较远”,连接速度比较慢,connect 函数在连接过程中可能会导致程序阻塞在 connect 函数处好一会儿(如两三秒之久),虽然这一般也不会对依赖于网络通信的程序造成什么影响,但在实际项目中,我们一般倾向使用所谓的异步的 connect 技术,或者叫非阻塞的 connect。这个流程一般有如下步骤:

1. 创建socket,并将 socket 设置成非阻塞模式;
2. 调用 connect 函数,此时无论 connect 函数是否连接成功会立即返回;如果返回-1并不表示连接出错,如果此时错误码是EINPROGRESS
3. 接着调用 select 函数,在指定的时间内判断该 socket 是否可写,如果可写说明连接成功,反之则认为连接失败。

按上述流程编写代码如下:

/**
 * 异步的connect写法,nonblocking_connect.cpp
 * zhangyl 2018.12.17
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>

#define SERVER_ADDRESS "127.0.0.1"
#define SERVER_PORT     3000
#define SEND_DATA       "helloworld"

int main(int argc, char* argv[])
{
    //1.创建一个socket
    int clientfd = socket(AF_INET, SOCK_STREAM, 0);
    if (clientfd == -1)
    {
        std::cout << "create client socket error." << std::endl;
        return -1;
    }

    //连接成功以后,我们再将 clientfd 设置成非阻塞模式,
    //不能在创建时就设置,这样会影响到 connect 函数的行为
    int oldSocketFlag = fcntl(clientfd, F_GETFL, 0);
    int newSocketFlag = oldSocketFlag | O_NONBLOCK;
    if (fcntl(clientfd, F_SETFL,  newSocketFlag) == -1)
    {
        close(clientfd);
        std::cout << "set socket to nonblock error." << std::endl;
        return -1;
    }

    //2.连接服务器
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
    serveraddr.sin_port = htons(SERVER_PORT);
    for (;;)
    {
        int ret = connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
        if (ret == 0)
        {
            std::cout << "connect to server successfully." << std::endl;
            close(clientfd);
            return 0;
        } 
        else if (ret == -1) 
        {
            if (errno == EINTR)
            {
                //connect 动作被信号中断,重试connect
                std::cout << "connecting interruptted by signal, try again." << std::endl;
                continue;
            } else if (errno == EINPROGRESS)
            {
                //连接正在尝试中
                break;
            } else {
                //真的出错了,
                close(clientfd);
                return -1;
            }
        }
    }

    fd_set writeset;
    FD_ZERO(&writeset);
    FD_SET(clientfd, &writeset);
    //可以利用tv_sec和tv_usec做更小精度的超时控制
    struct timeval tv;
    tv.tv_sec = 3;  
    tv.tv_usec = 0;
    if (select(clientfd + 1, NULL, &writeset, NULL, &tv) == 1)
    {
        std::cout << "[select] connect to server successfully." << std::endl;
    } else {
        std::cout << "[select] connect to server error." << std::endl;
    }

    //5. 关闭socket
    close(clientfd);

    return 0;
}

为了区别到底是在调用 connect 函数时判断连接成功还是通过 select 函数判断连接成功,我们在后者的输出内容中加上了“[select]”标签以示区别。

我们先用 nc 命令启动一个服务器程序:

nc -v -l 0.0.0.0 3000

然后编译客户端程序并执行:

[root@localhost testsocket]# g++ -g -o nonblocking_connect nonblocking_connect.cpp 
[root@localhost testsocket]# ./nonblocking_connect 
[select] connect to server successfully.

我们把服务器程序关掉,再重新启动一下客户端,这个时候应该会连接失败,程序输出结果如下:

[root@localhost testsocket]# ./nonblocking_connect 
[select] connect to server successfully.

奇怪?为什么连接不上也会得出一样的输出结果?难道程序有问题?这是因为:

  • 在 Windows 系统上,一个 socket 没有建立连接之前,我们使用 select 函数检测其是否可写,能得到正确的结果(不可写),连接成功后检测,会变为可写。所以,上述介绍的异步 connect 写法流程在 Windows 系统上时没有问题的。
  • 在 Linux 系统上一个 socket 没有建立连接之前,用 select 函数检测其是否可写,你也会得到可写得结果,所以上述流程并不适用于 Linux 系统。正确的做法是,connect 之后,不仅要用 select 检测可写,还要检测此时 socket 是否出错,通过错误码来检测确定是否连接上,错误码为 0 表示连接上,反之为未连接上。完整代码如下:
  /**
   * Linux 下正确的异步的connect写法,linux_nonblocking_connect.cpp
   * zhangyl 2018.12.17
   */
  #include <sys/types.h> 
  #include <sys/socket.h>
  #include <arpa/inet.h>
  #include <unistd.h>
  #include <iostream>
  #include <string.h>
  #include <stdio.h>
  #include <fcntl.h>
  #include <errno.h>

  #define SERVER_ADDRESS "127.0.0.1"
  #define SERVER_PORT     3000
  #define SEND_DATA       "helloworld"

  int main(int argc, char* argv[])
  {
      //1.创建一个socket
      int clientfd = socket(AF_INET, SOCK_STREAM, 0);
      if (clientfd == -1)
      {
          std::cout << "create client socket error." << std::endl;
          return -1;
      }

      //连接成功以后,我们再将 clientfd 设置成非阻塞模式,
      //不能在创建时就设置,这样会影响到 connect 函数的行为
      int oldSocketFlag = fcntl(clientfd, F_GETFL, 0);
      int newSocketFlag = oldSocketFlag | O_NONBLOCK;
      if (fcntl(clientfd, F_SETFL,  newSocketFlag) == -1)
      {
          close(clientfd);
          std::cout << "set socket to nonblock error." << std::endl;
          return -1;
      }

      //2.连接服务器
      struct sockaddr_in serveraddr;
      serveraddr.sin_family = AF_INET;
      serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
      serveraddr.sin_port = htons(SERVER_PORT);
      for (;;)
      {
          int ret = connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
          if (ret == 0)
          {
              std::cout << "connect to server successfully." << std::endl;
              close(clientfd);
              return 0;
          } 
          else if (ret == -1) 
          {
              if (errno == EINTR)
              {
                  //connect 动作被信号中断,重试connect
                  std::cout << "connecting interruptted by signal, try again." << std::endl;
                  continue;
              } else if (errno == EINPROGRESS)
              {
                  //连接正在尝试中
                  break;
              } else {
                  //真的出错了,
                  close(clientfd);
                  return -1;
              }
          }
      }

      fd_set writeset;
      FD_ZERO(&writeset);
      FD_SET(clientfd, &writeset);
      //可以利用tv_sec和tv_usec做更小精度的超时控制
      struct timeval tv;
      tv.tv_sec = 3;  
      tv.tv_usec = 0;
      if (select(clientfd + 1, NULL, &writeset, NULL, &tv) != 1)
      {
          std::cout << "[select] connect to server error." << std::endl;
          close(clientfd);
          return -1;
      }

      int err;
      socklen_t len = static_cast<socklen_t>(sizeof err);
      if (::getsockopt(clientfd, SOL_SOCKET, SO_ERROR, &err, &len) < 0)
      {
          close(clientfd);
          return -1;
      }

      if (err == 0)
          std::cout << "connect to server successfully." << std::endl;
      else
          std::cout << "connect to server error." << std::endl;

      //5. 关闭socket
      close(clientfd);

      return 0;
  }
当然,在实际的项目中,第 3 个步骤中 Linux 平台上你也可以使用 poll 函数来判断 socket 是否可写;在 Windows 平台上你可以使用 WSAEventSelect 或 WSAAsyncSelect 函数判断连接是否成功,关于这三个函数我们将在后面的章节中详细讲解,这里暂且仅以 select 函数为例。

select函数重难点解析

select 函数重难点解析

select 函数是网络通信编程中非常常用的一个函数,因此应该熟练掌握它。虽然它是 BSD 标准之一的 Socket 函数之一,但在 Linux 和 Windows 平台,其行为表现还是有点区别的。我们先来看一下 Linux 平台上的 select 函数。

Linux 平台下的 select 函数

select 函数的作用是检测一组 socket 中某个或某几个是否有“事件”,这里的“事件”一般分为如下三类:

  • 可读事件,一般意味着可以调用 recv 或 read 函数从该 socket 上读取数据;如果该 socket 是侦听 socket(即调用了 bind 函数绑定过 ip 地址和端口号,并调用了 listen 启动侦听的 socket),可读意味着此时可以有新的客户端连接到来,此时可调用 accept 函数接受新连接。
  • 可写事件,一般意味着此时调用 send 或 write 函数可以将数据“发出去”。
  • 异常事件,某个 socket 出现异常。

函数签名如下:

int select(int nfds, 
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

参数说明:

  • 参数 nfds, Linux 下 socket 也称 fd,这个参数的值设置成所有需要使用 select 函数监听的 fd 中最大 fd 值加 1。
  • 参数 readfds,需要监听可读事件的 fd 集合。
  • 参数 writefds,需要监听可写事件的 fd 集合。
  • 参数 exceptfds,需要监听异常事件 fd 集合。

readfds、writefds 和 exceptfds 类型都是 fd_set,这是一个结构体信息,其定义位于 /usr/include/sys/select.h 中:

  /* The fd_set member is required to be an array of longs.  */
  typedef long int __fd_mask;
  
  /* Some versions of <linux/posix_types.h> define this macros.  */
  #undef  __NFDBITS
  /* It's easier to assume 8-bit bytes than to get CHAR_BIT.  */
  #define __NFDBITS       (8 * (int) sizeof (__fd_mask))
  #define __FD_ELT(d)     ((d) / __NFDBITS)
  #define __FD_MASK(d)    ((__fd_mask) 1 << ((d) % __NFDBITS))
  
  /* fd_set for select and pselect.  */
  typedef struct
  {
    /* XPG4.2 requires this member name.  Otherwise avoid the name
       from the global namespace.  */
  #ifdef __USE_XOPEN
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
  # define __FDS_BITS(set) ((set)->fds_bits)
  #else
    // 在我的centOS 7.0 系统中的值:
    // __FD_SETSIZE = 1024
    //__NFDBITS = 64
    __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];     
  # define __FDS_BITS(set) ((set)->__fds_bits)
  #endif
  } fd_set;
  
  /* Maximum number of file descriptors in 'fd_set'.  */
  #define FD_SETSIZE              __FD_SETSIZE

我们假设未定义宏 __USE_XOPEN,将上面的代码整理一下:

  typedef struct
  { 
    long int __fds_bits[16];     
  } fd_set;

将一个 fd 添加到 fd_set 这个集合中需要使用 FD_SET 宏,其定义如下:

  void FD_SET(int fd, fd_set *set);

其实现如下:

  #define FD_SET(fd,fdsetp) __FD_SET(fd,fdsetp)

FD_SET 在内部又是通过宏 __FD_SET 来实现的,__FD_SET 的定义如下(位于 /usr/include/bits/select.h 中):

  #if defined __GNUC__ && __GNUC__ >= 2
  
  # if __WORDSIZE == 64
  #  define __FD_ZERO_STOS "stosq"
  # else
  #  define __FD_ZERO_STOS "stosl"
  # endif
  
  # define __FD_ZERO(fdsp) \
  do {                                                                        \
    int __d0, __d1;                                                           \
    __asm__ __volatile__ ("cld; rep; " __FD_ZERO_STOS                         \
                          : "=c" (__d0), "=D" (__d1)                          \
                          : "a" (0), "0" (sizeof (fd_set)                     \
                                          / sizeof (__fd_mask)),              \
                            "1" (&__FDS_BITS (fdsp)[0])                       \
                          : "memory");                                        \
  } while (0)
  
  #else   /* ! GNU CC */
  
  /* We don't use `memset' because this would require a prototype and
   the array isn't too big.  */
  # define __FD_ZERO(set)  \
  do {                                                                        \
    unsigned int __i;                                                         \
    fd_set *__arr = (set);                                                    \
    for (__i = 0; __i < sizeof (fd_set) / sizeof (__fd_mask); ++__i)          \
      __FDS_BITS (__arr)[__i] = 0;                                            \
  } while (0)
  
  #endif  /* GNU CC */
  
  #define __FD_SET(d, set) \
  ((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))
  #define __FD_CLR(d, set) \
  ((void) (__FDS_BITS (set)[__FD_ELT (d)] &= ~__FD_MASK (d)))
  #define __FD_ISSET(d, set) \
  ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

重点看这一行:

  ((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))

__FD_MASK 和 __FD_ELT 宏在上面的代码中已经给出定义:

  #define __FD_ELT(d)     ((d) / __NFDBITS)
  #define __FD_MASK(d)    ((__fd_mask) 1 << ((d) % __NFDBITS))

__NFDBITS 的值是 64 (**8 \ 8),也就是说 __FD_MASK (d) 先计算 fd 与 64 的余数 n,然后执行 1 << n,这一操作实际上是将 fd 的值放在 0~63 这 64 的位置上去,这个位置索引就是 fd 与 64 取模的结果;同理 __FD_ELT(d)* 就是计算位置索引值了。举个例子,假设现在 fd 的 值是 57,那么在这 64 个位置的 57 位,其值在 64 个长度的二进制中置位是:

  0000 0010 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

这个值就是 1 << (57 % 64) 得到的数字。

但是前面 fd 数组的定义是:

  typedef struct
  { 
    long int __fds_bits[16];    //可以看成是128 bit的数组 
  } fd_set;

long int 占 8 个字节,一个 16 个 long int,如果换成二进制的位(bit)就是 **8 \ 16 = 128*, 也就是这个数组只用了低 64 位, 高 64 位并没有使用。这说明在我的机器上,select 函数支持操作的最大 fd 数量是 64。

同理,如果我们需要从 fd_set 上删除一个 fd,我们可以调用 FD_CLR,其定义如下:

  void FD_CLR(int fd, fd_set *set);

原理和 FD_SET 相同,即将对应的标志位由1变0即可。

如果,我们需要将 fd_set 中所有的 fd 都清掉,则使用宏 FD_ZERO:

  void FD_ZERO(fd_set *set);

当 select 函数返回时, 我们使用 FD_ISSET 宏来判断某个 fd 是否有我们关心的事件,FD_ISSET 宏的定义如下:

int  FD_ISSET(int fd, fd_set *set);

FD_ISSET 宏本质上就是检测对应的位置上是否置 1,实现如下:

#define __FD_ISSET(d, set) \
    ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)
提醒一下: __FD_ELT 和 __FD_MASK 宏前文的代码已经给过具体实现了。
  • 参数 timeout,超时时间,即在这个参数设定的时间内检测这些 fd 的事件,超过这个时间后 select 函数将立即返回。这是一个 timeval 类型结构体,其定义如下:
  struct timeval 
  {
    long    tv_sec;         /* seconds */
    long    tv_usec;        /* microseconds */
  };

select 函数的总超时时间是 timeout->tv_sec 和 timeout->tv_usec 之和, 前者的时间单位是秒,后者的时间单位是微妙。

说了这么多理论知识,我们先看一个具体的示例:

/**
 * select函数示例,server端, select_server.cpp
 * zhangyl 2018.12.24
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <sys/time.h>
#include <vector>
#include <errno.h>

//自定义代表无效fd的值
#define INVALID_FD -1

int main(int argc, char* argv[])
{
    //创建一个侦听socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1)
    {
        std::cout << "create listen socket error." << std::endl;
        return -1;
    }

    //初始化服务器地址
    struct sockaddr_in bindaddr;
    bindaddr.sin_family = AF_INET;
    bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    bindaddr.sin_port = htons(3000);
    if (bind(listenfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
    {
        std::cout << "bind listen socket error." << std::endl;
        close(listenfd);
        return -1;
    }

    //启动侦听
    if (listen(listenfd, SOMAXCONN) == -1)
    {
        std::cout << "listen error." << std::endl;
        close(listenfd);
        return -1;
    }

    //存储客户端socket的数组
    std::vector<int> clientfds;
    int maxfd = listenfd;

    while (true) 
    {   
        fd_set readset;
        FD_ZERO(&readset);

        //将侦听socket加入到待检测的可读事件中去
        FD_SET(listenfd, &readset);

        //将客户端fd加入到待检测的可读事件中去
        int clientfdslength = clientfds.size();
        for (int i = 0; i < clientfdslength; ++i)
        {
            if (clientfds[i] != INVALID_FD)
            {
                FD_SET(clientfds[i], &readset);
            }
        }

        timeval tm;
        tm.tv_sec = 1;
        tm.tv_usec = 0;
        //暂且只检测可读事件,不检测可写和异常事件
        int ret = select(maxfd + 1, &readset, NULL, NULL, &tm);
        if (ret == -1)
        {
            //出错,退出程序。
            if (errno != EINTR)
                break;
        }
        else if (ret == 0)
        {
            //select 函数超时,下次继续
            continue;
        } else {
            //检测到某个socket有事件
            if (FD_ISSET(listenfd, &readset))
            {
                //侦听socket的可读事件,则表明有新的连接到来
                struct sockaddr_in clientaddr;
                socklen_t clientaddrlen = sizeof(clientaddr);
                //4. 接受客户端连接
                int clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientaddrlen);
                if (clientfd == -1)                 
                {           
                    //接受连接出错,退出程序
                    break;
                }

                //只接受连接,不调用recv收取任何数据
                std:: cout << "accept a client connection, fd: " << clientfd << std::endl;
                clientfds.push_back(clientfd);
                //记录一下最新的最大fd值,以便作为下一轮循环中select的第一个参数
                if (clientfd > maxfd)
                    maxfd = clientfd;
            } 
            else 
            {
                //假设对端发来的数据长度不超过63个字符
                char recvbuf[64];
                int clientfdslength = clientfds.size();
                for (int i = 0; i < clientfdslength; ++i)
                {
                    if (clientfds[i] != -1 && FD_ISSET(clientfds[i], &readset))
                    {               
                        memset(recvbuf, 0, sizeof(recvbuf));
                        //非侦听socket,则接收数据
                        int length = recv(clientfds[i], recvbuf, 64, 0);
                        if (length <= 0 && errno != EINTR)
                        {
                            //收取数据出错了
                            std::cout << "recv data error, clientfd: " << clientfds[i] << std::endl;                            
                            close(clientfds[i]);
                            //不直接删除该元素,将该位置的元素置位-1
                            clientfds[i] = INVALID_FD;
                            continue;
                        }

                        std::cout << "clientfd: " << clientfds[i] << ", recv data: " << recvbuf << std::endl;                   
                    }
                }

            }
        }
    }

    //关闭所有客户端socket
    int clientfdslength = clientfds.size();
    for (int i = 0; i < clientfdslength; ++i)
    {
        if (clientfds[i] != INVALID_FD)
        {
            close(clientfds[i]);
        }
    }

    //关闭侦听socket
    close(listenfd);

    return 0;
}

我们编译并运行程序:

[root@localhost testsocket]# g++ -g -o select_server select_server.cpp 
[root@localhost testsocket]# ./select_server 

然后,我们再多开几个 shell 窗口,我们这里不再专门编写客户端程序了,我们使用 Linux 下的 nc 指令模拟出两个客户端。

shell 窗口1,连接成功以后发送字符串 hello123:

[root@localhost ~]# nc -v 127.0.0.1 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:3000.
hello123

shell 窗口2,连接成功以后发送字符串 helloworld:

[root@localhost ~]# nc -v 127.0.0.1 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:3000.
helloworld

此时服务器端输出结果如下:

注意,由于 nc 发送的数据是按换行符来区分的,每一个数据包默认的换行符以\n 结束(当然,你可以 -C 选项换成\r\n),所以服务器收到数据后,显示出来的数据每一行下面都有一个空白行。

当断开各个客户端连接时,服务器端 select 函数对各个客户端 fd 检测时,仍然会触发可读事件,此时对这些 fd 调用 recv 函数会返回 0(recv 函数返回0,表明对端关闭了连接,这是一个很重要的知识点,下文我们会有一章节专门介绍这些函数的返回值),服务器端也关闭这些连接就可以了。

客户端断开连接后,服务器端的运行输出结果:

以上代码是一个简单的服务器程序实现的基本流程,代码虽然简单,但是非常具有典型性和代表性,而且同样适用于客户端网络通信,如果用于客户端的话,只需要用 select 检测连接 socket 就可以了,如果连接 socket 有可读事件,调用 recv 函数来接收数据,剩下的逻辑都是一样的。上面的代码我们画一张流程图如下:

关于上述代码在实际开发中有几个需要注意的事项,这里逐一来说明一下:

1. select 函数调用前后会修改 readfds、writefds 和 exceptfds 这三个集合中的内容(如果有的话),所以如果您想下次调用 select 复用这个变量,记得在下次调用前再次调用 select 前先使用 FD_ZERO 将集合清零,然后调用 FD_SET 将需要检测事件的 fd 再次添加进去。

select 函数调用之后,readfds、writefds 和 exceptfds 这三个集合中存放的不是我们之前设置进去的 fd,而是有相关有读写或异常事件的 fd,也就是说 select 函数会修改这三个参数的内容,这也要求我们当一个 fd_set 被 select 函数调用后,这个 fd_set 就已经发生了改变,下次如果我们需要使用它,必须使用 FD_ZERO 宏先清零,再重新将我们关心的 fd 设置进去。这点我们从 FD_ISSET 源码也可以看出来:
 #define __FD_ISSET(d, set) \
    ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

如果调用 select 函数之后没有改变 fd_set 集合,那么即使某个 socket 上没有事件,调用 select 函数之后我们用 FD_ISSET 检测,会原路得到原来设置上去的 socket。这是很多初学者在学习 select 函数容易犯的一个错误,我们通过一个示例来验证一下,这次我们把 select 函数用在客户端。

/**
 * 验证调用select后必须重设fd_set,select_client.cpp
 * zhangyl 2018.12.24
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <errno.h>
#include <string.h>

#define SERVER_ADDRESS "127.0.0.1"
#define SERVER_PORT     3000

int main(int argc, char* argv[])
{
    //创建一个socket
    int clientfd = socket(AF_INET, SOCK_STREAM, 0);
    if (clientfd == -1)
    {
        std::cout << "create client socket error." << std::endl;
        return -1;
    }

    //连接服务器
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
    serveraddr.sin_port = htons(SERVER_PORT);
    if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
    {
        std::cout << "connect socket error." << std::endl;
        close(clientfd);
        return -1;
    }

    fd_set readset;
    FD_ZERO(&readset);

    //将侦听socket加入到待检测的可读事件中去
    FD_SET(clientfd, &readset); 
    timeval tm;
    tm.tv_sec = 5;
    tm.tv_usec = 0; 
    int ret;
    int count = 0;
    fd_set backup_readset;
    memcpy(&backup_readset, &readset, sizeof(fd_set));
    while (true)
    {
        if (memcmp(&readset, &backup_readset, sizeof(fd_set)) == 0)
        {
            std::cout << "equal" << std::endl;
        }
        else
        {
            std::cout << "not equal" << std::endl;
        }

        //暂且只检测可读事件,不检测可写和异常事件
        ret = select(clientfd + 1, &readset, NULL, NULL, &tm);
        std::cout << "tm.tv_sec: " << tm.tv_sec << ", tm.tv_usec: " << tm.tv_usec << std::endl;
        if (ret == -1)
        {
            //除了被信号中断的情形,其他情况都是出错
            if (errno != EINTR)
                break;
        } else if (ret == 0){
            //select函数超时
            std::cout << "no event in specific time interval, count:" << count << std::endl;
            ++count;
            continue;
        } else {
            if (FD_ISSET(clientfd, &readset))
            {
                //检测到可读事件
                char recvbuf[32];
                memset(recvbuf, 0, sizeof(recvbuf));
                //假设对端发数据的时候不超过31个字符。
                int n = recv(clientfd, recvbuf, 32, 0);
                if (n < 0)
                {
                    //除了被信号中断的情形,其他情况都是出错
                    if (errno != EINTR)
                        break;
                } else if (n == 0) {
                    //对端关闭了连接
                    break;
                } else {
                    std::cout << "recv data: " << recvbuf << std::endl;
                }
            }
            else 
            {
                std::cout << "other socket event." << std::endl;
            }
        }
    }       

    //关闭socket
    close(clientfd);

    return 0;
}

在 shell 窗口输入以下命令编译程序产生可执行文件 select_client:

g++ -g -o select_client select_client.cpp

这次产生的是客户端程序,服务器程序我们这里使用 Linux nc 命令来模拟一下,由于客户端连接的是 127.0.0.1:3000 这个地址和端口号,所以我们在另外一个shell 窗口的 nc 命令的参数可以这么写:

nc -v -l 0.0.0.0 3000

执行效果如下:接着我们启动客户端 select_client:

[root@myaliyun testsocket]# ./select_client 

需要注意的是,这里我故意将客户端代码中 select 函数的超时时间设置为5秒,以足够我们在这 5 秒内给客户端发一个数据。如果我们在 5 秒内给客户端发送 hello 字符串:

客户端输出如下:

[root@myaliyun testsocket]# ./select_client 
equal
recv data: hello

...部分数据省略...
not equal
tm.tv_sec: 0, tm.tv_usec: 0
no event in specific time interval, count:31454
not equal
tm.tv_sec: 0, tm.tv_usec: 0
no event in specific time interval, count:31455
not equal
tm.tv_sec: 0, tm.tv_usec: 0
no event in specific time interval, count:31456
not equal
tm.tv_sec: 0, tm.tv_usec: 0
no event in specific time interval, count:31457
...部分输出省略...

除了第一次 select_client 会输出 equal 字样,后面再也没输出,而 select 函数以后的执行结果也是超时,即使此时服务器端再次给客户端发送数据。因此验证了:select 函数执行后,确实会对三个参数的 fd_set 进行修改 。select 函数修改某个 fd_set 集合可以使用如下两张图来说明一下:

因此在调用 select 函数以后, 原来位置的的标志位可能已经不复存在,这也就是为什么我们的代码中调用一次 select 函数以后,即使服务器端再次发送数据过来,select 函数也不会再因为存在可读事件而返回了,因为第二次 clientfd 已经不在那个 read_set 中了。因此如果复用这些 fd_set 变量,必须按上文所说的重新清零再重新添加关心的 socket 到集合中去。

2. select 函数也会修改 timeval 结构体的值,这也要求我们如果像复用这个变量,必须给 timeval 变量重新设置值。

注意观察上面的例子的输出,我们在调用 select 函数一次之后,变量 tv 的值也被修改了。具体修改成多少,得看系统的表现。当然这种特性却不是跨平台的,在 Linux 系统中是这样的,而在其他操作系统上却不一定是这样(Windows 上就不会修改这个结构体的值),这点在 Linux man 手册 select 函数的说明中说的很清楚:

On  Linux,  select()  modifies timeout to reflect the amount
of time not slept; most other implementations do not do this.
(POSIX.1-2001 permits either behavior.)  This causes problems 
both when Linux code which reads timeout is ported to  other 
operating systems, and when code is ported to Linux that reuses
a struct timeval for multiple select()s in a loop without
reinitializing it.  Consider timeout to be undefined after
select() returns.

由于不同系统的实现不一样,man 手册的建议将 select 函数修改 timeval 结构体的值的行为当作是未定义的,言下之意是如果你要下次使用 select 函数复用这个变量时,记得重新赋值。这是 select 函数需要注意的第二个地方。

3. select 函数的 timeval 结构体的 tv_sec 和 tv_sec 如果两个值设置为 0,即检测事件总时间设置为0,其行为是 select 会检测一下相关集合中的 fd,如果没有需要的事件,则立即返回。

我们将上述 select_client.cpp 修改一下,修改后的代码如下:

   /**
    * 验证select时间参数设置为0,select_client_tv0.cpp
    * zhangyl 2018.12.25
    */
   #include <sys/types.h> 
   #include <sys/socket.h>
   #include <arpa/inet.h>
   #include <unistd.h>
   #include <iostream>
   #include <string.h>
   #include <errno.h>
   #include <string.h>

   #define SERVER_ADDRESS "127.0.0.1"
   #define SERVER_PORT     3000

   int main(int argc, char* argv[])
   {
       //创建一个socket
       int clientfd = socket(AF_INET, SOCK_STREAM, 0);
       if (clientfd == -1)
       {
           std::cout << "create client socket error." << std::endl;
           return -1;
       }

       //连接服务器
       struct sockaddr_in serveraddr;
       serveraddr.sin_family = AF_INET;
       serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
       serveraddr.sin_port = htons(SERVER_PORT);
       if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
       {
           std::cout << "connect socket error." << std::endl;
           close(clientfd);
           return -1;
       }

       int ret;
       while (true)
       {
           fd_set readset;
           FD_ZERO(&readset);
           //将侦听socket加入到待检测的可读事件中去
           FD_SET(clientfd, &readset); 
           timeval tm;
           tm.tv_sec = 0;
           tm.tv_usec = 0; 

           //暂且只检测可读事件,不检测可写和异常事件
           ret = select(clientfd + 1, &readset, NULL, NULL, &tm);
           std::cout << "tm.tv_sec: " << tm.tv_sec << ", tm.tv_usec: " << tm.tv_usec << std::endl;
           if (ret == -1)
           {
               //除了被信号中断的情形,其他情况都是出错
               if (errno != EINTR)
                   break;
           } else if (ret == 0){
               //select函数超时
               std::cout << "no event in specific time interval." << std::endl;
               continue;
           } else {
               if (FD_ISSET(clientfd, &readset))
               {
                   //检测到可读事件
                   char recvbuf[32];
                   memset(recvbuf, 0, sizeof(recvbuf));
                   //假设对端发数据的时候不超过31个字符。
                   int n = recv(clientfd, recvbuf, 32, 0);
                   if (n < 0)
                   {
                       //除了被信号中断的情形,其他情况都是出错
                       if (errno != EINTR)
                           break;
                   } else if (n == 0) {
                       //对端关闭了连接
                       break;
                   } else {
                       std::cout << "recv data: " << recvbuf << std::endl;
                   }
               }
               else 
               {
                   std::cout << "other socket event." << std::endl;
               }
           }
       }       


       //关闭socket
       close(clientfd);

       return 0;
   }

执行结果确实如我们预期的,这里 select 函数只是简单地检测一下 clientfd,并不会等待固定的时间,然后立即返回。

4. 如果将 select 函数的 timeval 参数设置为 NULL,则 select 函数会一直阻塞下去,直到我们需要的事件触发。

我们将上述代码再修改一下:

   /**
    * 验证select时间参数设置为NULL,select_client_tvnull.cpp
    * zhangyl 2018.12.25
    */
   #include <sys/types.h> 
   #include <sys/socket.h>
   #include <arpa/inet.h>
   #include <unistd.h>
   #include <iostream>
   #include <string.h>
   #include <errno.h>
   #include <string.h>

   #define SERVER_ADDRESS "127.0.0.1"
   #define SERVER_PORT     3000

   int main(int argc, char* argv[])
   {
       //创建一个socket
       int clientfd = socket(AF_INET, SOCK_STREAM, 0);
       if (clientfd == -1)
       {
           std::cout << "create client socket error." << std::endl;
           return -1;
       }

       //连接服务器
       struct sockaddr_in serveraddr;
       serveraddr.sin_family = AF_INET;
       serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
       serveraddr.sin_port = htons(SERVER_PORT);
       if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
       {
           std::cout << "connect socket error." << std::endl;
           close(clientfd);
           return -1;
       }

       int ret;
       while (true)
       {
           fd_set readset;
           FD_ZERO(&readset);
           //将侦听socket加入到待检测的可读事件中去
           FD_SET(clientfd, &readset); 
           //timeval tm;
           //tm.tv_sec = 0;
           //tm.tv_usec = 0;   

           //暂且只检测可读事件,不检测可写和异常事件
           ret = select(clientfd + 1, &readset, NULL, NULL, NULL);
           if (ret == -1)
           {
               //除了被信号中断的情形,其他情况都是出错
               if (errno != EINTR)
                   break;
           } else if (ret == 0){
               //select函数超时
               std::cout << "no event in specific time interval." << std::endl;
               continue;
           } else {
               if (FD_ISSET(clientfd, &readset))
               {
                   //检测到可读事件
                   char recvbuf[32];
                   memset(recvbuf, 0, sizeof(recvbuf));
                   //假设对端发数据的时候不超过31个字符。
                   int n = recv(clientfd, recvbuf, 32, 0);
                   if (n < 0)
                   {
                       //除了被信号中断的情形,其他情况都是出错
                       if (errno != EINTR)
                           break;
                   } else if (n == 0) {
                       //对端关闭了连接
                       break;
                   } else {
                       std::cout << "recv data: " << recvbuf << std::endl;
                   }
               }
               else 
               {
                   std::cout << "other socket event." << std::endl;
               }
           }
       }       


       //关闭socket
       close(clientfd);

       return 0;
   }

我们先在另外一个 shell 窗口用 nc 命令模拟一个服务器,监听的 ip 地址和端口号是 0.0.0.0:3000:

[root@myaliyun ~]# nc -v -l 0.0.0.0 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Listening on 0.0.0.0:3000

然后回到原来的 shell 窗口,编译上述 select_client_tvnull.cpp,并使用 gdb 运行程序,这次使用 gdb 运行程序的目的是为了当程序“卡”在某个位置时,我们可以使用 Ctrl + C 把程序中断下来看看程序阻塞在哪个函数调用处:

[root@myaliyun testsocket]# g++ -g -o select_client_tvnull select_client_tvnull.cpp 
[root@myaliyun testsocket]# gdb select_client_tvnull
Reading symbols from /root/testsocket/select_client_tvnull...done.
(gdb) r
Starting program: /root/testsocket/select_client_tvnull 
^C
Program received signal SIGINT, Interrupt.
0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-196.el7_4.2.x86_64 libgcc-4.8.5-16.el7_4.1.x86_64 libstdc++-4.8.5-16.el7_4.1.x86_64
(gdb) bt
#0  0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6
#1  0x0000000000400c75 in main (argc=1, argv=0x7fffffffe5f8) at select_client_tvnull.cpp:51
(gdb) c
Continuing.
recv data: hello

^C
Program received signal SIGINT, Interrupt.
0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6
(gdb) c
Continuing.
recv data: world

如上输出结果所示,我们使用 gdb 的 r 命令(run)将程序跑起来后,程序卡在某个地方,我们按 Ctrl + C(代码中的 ^C)中断程序后使用 bt 命令查看当前程序的调用堆栈,发现确实阻塞在 select 函数调用处;接着我们在服务器端给客户端发送一个 hello 数据:

[root@myaliyun ~]# nc -v -l 0.0.0.0 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Listening on 0.0.0.0:3000
Ncat: Connection from 127.0.0.1.
Ncat: Connection from 127.0.0.1:55968.
hello

客户端收到数据后,select 函数满足条件,立即返回,并将数据输出来后继续进行下一轮 select 检测,我们使用 Ctrl + C 将程序中断,发现程序又阻塞在 select 调用处;输入 c 命令(continue)让程序继续运行, 此时,我们再用服务器端给客户端发送 world 字符串,select 函数再次返回,并将数据打印出来,然后继续进入下一轮 select 检测,并继续在 select 处阻塞。

[root@myaliyun ~]# nc -v -l 0.0.0.0 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Listening on 0.0.0.0:3000
Ncat: Connection from 127.0.0.1.
Ncat: Connection from 127.0.0.1:55968.
hello
world

5. 在 Linux 平台上,select 函数的第一个参数必须设置成需要检测事件的所有 fd 中的最大值加1。所以上文中 select_server.cpp 中,每新产生一个 clientfd,我都会与当前最大的 maxfd 作比较,如果大于当前的 maxfd 则将 maxfd 更新成这个新的最大值。其最终目的是为了在 select 调用时作为第一个参数(加 1)传进去。

在 Windows 平台上,select 函数的第一个值传任意值都可以,Windows 系统本身不使用这个值,只是为了兼容性而保留了这个参数,但是在实际开发中为了兼容跨平台代码,也会按惯例,将这个值设置为最大 socket 加 1。这点请读者注意。

以上是我总结的 Linux 下 select 使用的五个注意事项,希望读者能理解它们。


Linuxepoll模型(含LT模式和ET模式详解)

Linux epoll 模型(含LT 模式和 ET 模式详解)

综合 select 和 poll 的一些优缺点,Linux 从内核 2.6 版本开始引入了更高效的 epoll 模型,本节我们来详细介绍 epoll 模型。

要想使用 epoll 模型,必须先需要创建一个 epollfd,这需要使用 epoll_create 函数去创建:

#include <sys/epoll.h>

int epoll_create(int size);

参数 size 从 Linux 2.6.8 以后就不再使用,但是必须设置一个大于 0 的值。epoll_create 函数调用成功返回一个非负值的 epollfd,调用失败返回 -1。

有了 epollfd 之后,我们需要将我们需要检测事件的其他 fd 绑定到这个 epollfd 上,或者修改一个已经绑定上去的 fd 的事件类型,或者在不需要时将 fd 从 epollfd 上解绑,这都可以使用 epoll_ctl 函数:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

参数说明:

  • 参数 epfd 即上文提到的 epollfd;
  • 参数 op,操作类型,取值有 EPOLL_CTL_ADD、EPOLL_CTL_MOD 和 EPOLL_CTL_DEL,分别表示向 epollfd 上添加、修改和移除一个其他 fd,当取值是 EPOLL_CTL_DEL,第四个参数 event 忽略不计,可以设置为 NULL;
  • 参数 fd,即需要被操作的 fd;
  • 参数 event,这是一个 epoll_event 结构体的地址,epoll_event 结构体定义如下:
  struct epoll_event
  {
    uint32_t     events;      /* 需要检测的 fd 事件,取值与 poll 函数一样 */
    epoll_data_t data;        /* 用户自定义数据 */
  };

epoll_event 结构体的 data 字段的类型是 epoll_data_t,我们可以利用这个字段设置一个自己的自定义数据,它本质上是一个 Union 对象,在 64 位操作系统中其大小是 8 字节,其定义如下:

  typedef union epoll_data
  {
    void*         ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
  } epoll_data_t;
  • 函数返回值:epoll_ctl 调用成功返回 0,调用失败返回 -1,你可以通过 errno 错误码获取具体的错误原因。

创建了 epollfd,设置好某个 fd 上需要检测事件并将该 fd 绑定到 epollfd 上去后,我们就可以调用 epoll_wait 检测事件了,epoll_wait 函数签名如下:

int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

参数的形式和 poll 函数很类似,参数 events 是一个 epoll_event 结构数组的首地址,这是一个输出参数,函数调用成功后,events 中存放的是与就绪事件相关 epoll_event 结构体数组;参数 maxevents 是数组元素的个数;timeout 是超时时间,单位是毫秒,如果设置为 0,epoll_wait 会立即返回。

当 epoll_wait 调用成功会返回有事件的 fd 数目;如果返回 0 表示超时;调用失败返回 -1。

epoll_wait 使用示例如下:

while (true)
{
    epoll_event epoll_events[1024];
    int n = epoll_wait(epollfd, epoll_events, 1024, 1000);
    if (n < 0)
    {
        //被信号中断
        if (errno == EINTR)
            continue;

        //出错,退出
        break;
    }
    else if (n == 0)
    {
        //超时,继续
        continue;
    }

    for (size_t i = 0; i < n; ++i)
    {
        // 处理可读事件
        if (epoll_events[i].events & POLLIN)
        {
        }
        // 处理可写事件
        else if (epoll_events[i].events & POLLOUT)
        {
        }
        //处理出错事件
        else if (epoll_events[i].events & POLLERR)
        {
        }
    }
}

epoll_wait 与 poll 的区别

通过前面介绍 poll 与 epoll_wait 函数的介绍,我们可以发现:

epoll_wait 函数调用完之后,我们可以直接在 event 参数中拿到所有有事件就绪的 fd,直接处理即可(event 参数仅仅是个出参);而 poll 函数的事件集合调用前后数量都未改变,只不过调用前我们通过 pollfd 结构体的 events 字段设置待检测事件,调用后我们需要通过 pollfd 结构体的 revents 字段去检测就绪的事件( 参数 fds 既是入参也是出参)。

举个生活中的例子,某人不断给你一些苹果,这些苹果有生有熟,调用 epoll_wait 相当于:

1. 你把苹果挨个投入到 epoll 机器中(调用 epoll_ctl);
2. 调用 epoll_wait 加工,你直接通过另外一个袋子就能拿到所有熟苹果。

调用 poll 相当于:

1. 把收到的苹果装入一个袋子里面然后调用 poll 加工;
2. 调用结束后,拿到原来的袋子,袋子中还是原来那么多苹果,只不过熟苹果被贴上了标签纸,你还是需要挨个去查看标签纸挑选熟苹果。 

当然,这并不意味着,poll 函数的效率不如 epoll_wait,一般在 fd 数量比较多,但某段时间内,就绪事件 fd 数量较少的情况下,epoll_wait 才会体现出它的优势,也就是说 socket 连接数量较大时而活跃连接较少时 epoll 模型更高效。

LT 模式和 ET 模式

与 poll 的事件宏相比,epoll 新增了一个事件宏 EPOLLET,这就是所谓的边缘触发模式(Edge Trigger,ET),而默认的模式我们称为 水平触发模式(Level Trigger,LT)。这两种模式的区别在于:

  • 对于水平触发模式,一个事件只要有,就会一直触发;
  • 对于边缘触发模式,只有一个事件从无到有才会触发。

这两个词汇来自电学术语,你可以将 fd 上有数据认为是高电平,没有数据认为是低电平,将 fd 可写认为是高电平,fd 不可写认为是低电平。那么水平模式的触发条件是状态处于高电平,而边缘模式是状态改为高电平,即:

水平模式的触发条件

1. 低电平 => 高电平
2. 高电平 => 高电平

边缘模式的触发条件

1. 低电平 => 高电平

说的有点抽象,以 socket 的读事件为例,对于水平模式,只要 socket 上有未读完的数据,就会一直产生 POLLIN 事件;而对于边缘模式,socket 上第一次有数据会触发一次,后续 socket 上存在数据也不会再触发,除非把数据读完后,再次产生数据才会继续触发。对于 socket 写事件,如果 socket 的 TCP 窗口一直不饱和,会一直触发 POLLOUT 事件;而对于边缘模式,只会触发一次,除非 TCP 窗口由不饱和变成饱和再一次变成不饱和,才会再次触发 POLLOUT 事件。

socket 可读事件水平模式触发条件:

1. socket上无数据 => socket上有数据
2. socket上有数据 => socket上有数据

socket 可读事件边缘模式触发条件:

1. socket上无数据 => socket上有数据

socket 可写事件水平模式触发条件:

1. socket可写   => socket可写
2. socket不可写 => socket可写

socket 可写事件边缘模式触发条件:

1. socket不可写 => socket可写

也就是说,如果对于一个非阻塞 socket,如果使用 epoll 边缘模式去检测数据是否可读,触发可读事件以后,一定要一次性把 socket 上的数据收取干净才行,也就是一定要循环调用 recv 函数直到 recv 出错,错误码是EWOULDBLOCK(EAGAIN 一样);如果使用水平模式,则不用,你可以根据业务一次性收取固定的字节数,或者收完为止。边缘模式下收取数据的代码示例如下:

bool TcpSession::RecvEtMode()
{
    //每次只收取256个字节
    char buff[256];
    while (true)
    {       
        int nRecv = ::recv(clientfd_, buff, 256, 0);
        if (nRecv == -1)
        {
            if (errno == EWOULDBLOCK)
                return true;
            else if (errno == EINTR)
                continue;

            return false;
        }
        //对端关闭了socket
        else if (nRecv == 0)
            return false;

        inputBuffer_.add(buff, (size_t)nRecv);
    }

    return true;
}

最后,我们来看一个 epoll 模型的完整例子:

/**
 * 演示 epoll 通信模型,epoll_server.cpp
 * zhangyl 2019.03.16
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <poll.h>
#include <iostream>
#include <string.h>
#include <vector>
#include <errno.h>

int main(int argc, char* argv[])
{
    //创建一个侦听socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1)
    {
        std::cout << "create listen socket error." << std::endl;
        return -1;
    }

    //将侦听socket设置为非阻塞的
    int oldSocketFlag = fcntl(listenfd, F_GETFL, 0);
    int newSocketFlag = oldSocketFlag | O_NONBLOCK;
    if (fcntl(listenfd, F_SETFL,  newSocketFlag) == -1)
    {
        close(listenfd);
        std::cout << "set listenfd to nonblock error." << std::endl;
        return -1;
    }

    //初始化服务器地址
    struct sockaddr_in bindaddr;
    bindaddr.sin_family = AF_INET;
    bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    bindaddr.sin_port = htons(3000);
    if (bind(listenfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
    {
        std::cout << "bind listen socket error." << std::endl;
        close(listenfd);
        return -1;
    }

    //启动侦听
    if (listen(listenfd, SOMAXCONN) == -1)
    {
        std::cout << "listen error." << std::endl;
        close(listenfd);
        return -1;
    }

    //复用地址和端口号
    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (char *)&on, sizeof(on));
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, (char *)&on, sizeof(on));

    //创建epollfd
    int epollfd = epoll_create(1);
    if (epollfd == -1)
    {
        std::cout << "create epollfd error." << std::endl;
        close(listenfd);
        return -1;
    }

    epoll_event listen_fd_event;
    listen_fd_event.events = POLLIN;
    listen_fd_event.data.fd = listenfd;

    //将侦听socket绑定到epollfd上去
    if(epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &listen_fd_event) == -1)
    {
        std::cout << "epoll_ctl error." << std::endl;
        close(listenfd);
        return -1;
    }

    int n;
    while (true)
    {       
        epoll_event epoll_events[1024];
        n = epoll_wait(epollfd, epoll_events, 1024, 1000);
        if (n < 0)
        {
            //被信号中断
            if (errno == EINTR)
                continue;

            //出错,退出
            break;
        }
        else if (n == 0)
        {
            //超时,继续
            continue;
        }

        for (size_t i = 0; i < n; ++i)
        {
            // 事件可读
            if (epoll_events[i].events & POLLIN)
            {
                if (epoll_events[i].data.fd == listenfd)
                {
                    //侦听socket,接受新连接
                    struct sockaddr_in clientaddr;
                    socklen_t clientaddrlen = sizeof(clientaddr);
                    //接受客户端连接, 并加入到fds集合中
                    int clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientaddrlen);
                    if (clientfd != -1)
                    {
                        //将客户端socket设置为非阻塞的
                        int oldSocketFlag = fcntl(clientfd, F_GETFL, 0);
                        int newSocketFlag = oldSocketFlag | O_NONBLOCK;
                        if (fcntl(clientfd, F_SETFL,  newSocketFlag) == -1)
                        {
                            close(clientfd);
                            std::cout << "set clientfd to nonblock error." << std::endl;                        
                        }
                        else
                        {
                            epoll_event client_fd_event;
                            client_fd_event.events = POLLIN;
                            client_fd_event.data.fd = clientfd;                         
                            if(epoll_ctl(epollfd, EPOLL_CTL_ADD, clientfd, &client_fd_event) != -1)
                            {
                                std::cout << "new client accepted, clientfd: " << clientfd << std::endl;
                            }
                            else
                            {
                                std::cout << "add client fd to epollfd error." << std::endl;
                                close(clientfd);                            
                            }
                        }       
                    }
                }
                else 
                {
                    //普通clientfd,收取数据
                    char buf[64] = { 0 };
                    int m = recv(epoll_events[i].data.fd, buf, 64, 0);
                    if (m == 0)
                    {
                        //对端关闭了连接,从epollfd上移除clientfd
                        if(epoll_ctl(epollfd, EPOLL_CTL_DEL, epoll_events[i].data.fd, NULL) != -1)
                        {
                            std::cout << "client disconnected, clientfd: " << epoll_events[i].data.fd << std::endl;
                        }
                        close(epoll_events[i].data.fd);
                    }
                    else if (m < 0)
                    {                                           
                        //出错,从epollfd上移除clientfd
                        if (errno != EWOULDBLOCK && errno != EINTR)
                        {
                            if(epoll_ctl(epollfd, EPOLL_CTL_DEL, epoll_events[i].data.fd, NULL) != -1)
                            {
                                std::cout << "client disconnected, clientfd: " << epoll_events[i].data.fd << std::endl;
                            }
                            close(epoll_events[i].data.fd);
                        }
                    }
                    else
                    {
                        //正常收到数据
                        std::cout << "recv from client: " << buf << ", clientfd: " << epoll_events[i].data.fd << std::endl;
                    }
                }
            }
            else if (epoll_events[i].events & POLLERR)
            {
                //TODO: 暂且不处理
            }

        }// end  outer-for-loop
    }// end  while-loop


    //关闭侦听socket
    //(理论上应该关闭包括所有clientfd在内的fd,但这里只是为了演示问题,就不写额外的代码来处理啦)
    close(listenfd);            

    return 0;
}

编译上述程序生成 epoll_server 并启动,然后使用 nc 命令启动三个客户端给服务器发数据效果如下图所示:

本文由 GitVP 从 GitHub 收录并在站内全文呈现,版权归原作者所有。

← 回到全部文章

同分类还有